# Project Premises — Map Layer

- **Date:** 2026-08-31
- **Status:** Complete
- **Status Date:** 2026-09-01
- **Phases:** 3
- **Phases Complete:** 3 (all 22 checks plus the six re-tests signed off 2026-09-01)
- **Notes:** Puts the UPRN table built by 056–058 on the project editor map. One read view
  (`projects.vw_project_premises_map`), one registered layer, three point styles. Nothing
  is materialised and nothing is written — this is a new way of looking at rows that
  already exist. The layer shows the viewing project's whole **lineage** (itself, its
  ancestors and its descendants) so opening any project in a 1-2-3 chain shows all three
  projects' premises, styled by where each premise is counted. **Siblings and cousins are
  out of scope here** and the reason is in Decision 2 — the honest version of that want is
  about adjacency, not hierarchy, and it is logged in `improvement-opportunities.md`.

## Plan Phases

1. ✅ The view and the layer registration (migration `063_project_premises_map_layer.sql`)
2. ✅ Point styles and popup copy (`map_layer_utils.js`, `project_edit_v2.js`)
3. ✅ Documentation

## Tested, and what changed

Tested on the dev server 2026-09-01, migrations 063 and 064 applied. 18 of the 22 checks
passed as written; the four below are what the rest of this section is about, and all of
them were signed off on re-test the same day.

**Test 8 — the sub-project teal read as green against the beige basemap.** `--gl-ok`
(`#0E6B62`) was chosen off the field-safe palette for its separation from the accent, and
that reasoning ignored the ground it would be drawn on: an OS basemap is already that hue
family, so the one thing the colour did reliably was blend. The three levels moved to **one
hue at three weights** — `#5A3D8A` solid r5, `#9B7FC7` solid r3.5, grey hollow r4.5 — which
removes the clash and says the right thing besides: these are three weights of the same
fact, not three unrelated states. Decision 1 carries the cost that accepts.

Migration **064** follows it, because 063 is applied and its tooltip named the teal. A
migration for a string, but the tooltip is the layer's only legend, so the colour word in it
is load-bearing. The new wording drops hue names for the ramp's ends — "solid / paler /
outlined" stays true if the hex moves again.

**Test 14 did not reach the case it was written for.** The layout tested was 2 and 3 both
under 1, overlapping by one premise, viewed from 2. Siblings do not invalidate each other —
a sibling is not a descendant — so the premise is valid at both, and within *2's* lineage
there is exactly one valid claimant: itself. Purple with `Counted Project: 2` is correct, and
Dave accepted it on the grounds that the existing overlap features already flag that premise.
The empty-Counted-Project path only appears from **project 1's** map, where both branches
claim it and there is no tie-break — re-tested from there and confirmed empty rather than
picking a branch.

**Tests 16, 17 and 18 are closed as existing behaviour, no change.** 16 and 17 have the same
single cause: `project_edit_v2.js:1806`, the layer checkbox handler, calls
`olLayer.setVisible(this.checked)` and nothing else. An OL vector source loads once, on first
render, and keeps those features for the life of the page, so unticking and re-ticking can
never refetch — a boundary edit or a re-parent shows on a page reload, not on a re-tick. That
matches the premise and opportunity lists, which do not refresh either, and the decision is to
follow existing behaviour rather than give this one layer its own refresh. Test 17's
underlying behaviour was then confirmed separately: setting a parent and **reloading** does
move the styles on the parent's map, so the 059 subtree re-flag reaches the layer and only
the refresh scope was ever in question. 18 (draw order) is a `z_index` value, settable in
Admin → Map Layers; the migration seeds 12 and nothing should hard-code it.

## Problem

`projects.project_premises` has been the answer to "what does this project contain" since
migration 058, and the Premises tab reads it — as a table. There is no way to *see* it. A
boundary on the map and a count on a tab are two different questions, and the ones people
actually ask standing in front of the map are spatial: where in this boundary are the
premises, where is the empty ground, and where does the density sit relative to the route
being planned.

The hierarchy makes that worse rather than better. A parent project's boundary contains its
children's premises, but those premises are *counted* at the child (the `is_valid` rule), so
a parent's map shows a boundary full of premises the parent's own numbers do not include.
Nothing on screen reconciles the two.

## Decisions

### 1. The style says where a premise is **counted**, not what the viewing project is

`premise_level` takes three values:

| Value | Meaning | Style |
|---|---|---|
| `this_project` | the viewing project holds the premise **and** counts it (`is_valid`) | solid `#5A3D8A`, r5 — most prominent |
| `sub_project` | something below the viewing project counts it | solid `#9B7FC7`, r3.5 |
| `parent_project` | only a project above holds it at all | hollow, `#6c757d` outline, r4.5 |

This is the same test the Premises tab chips use, and it is chosen for the same reason.
Keying on `hierarchy_role` instead would repeat the fault found during the premises build:
`hierarchy_role` only carries a value where an **ancestor** also holds the premise, so the
top project of a chain could never be styled for premises it counts while the one below it
was — the same fact rendered two ways depending on where you stood.

`sub_project` deliberately absorbs two cases that look different and are the same fact: the
viewing project holds the premise with `is_valid` false, and the viewing project does not
hold it but a descendant does. The only way to be invalid is for a descendant to hold it,
so both mean "counted below you".

Prominence is carried by **size and fill**, not hue — solid and largest for here, solid and
smaller for below, hollow for above. The three are one hue at three weights, because that is
what they are: three weights of the same fact, not three unrelated states.

`sub_project` was `--gl-ok` (`#0E6B62`) as first built, chosen off
`2026-07-27-field-safe-colour-scheme.md` for its separation from the accent. It read as plain
green against the beige basemap (Dave, 2026-09-01) — the palette was picked against white
chrome, and a map's ground is not white. The cost of the ramp, on the record: here and below
now separate by lightness and size rather than by hue, which is weaker under colour-vision
deficiency than a hue change. It is not a red/green pair, which is what that scheme is
guarding against, and the size difference carries the distinction independently of colour.
If `#9B7FC7` washes out on a paler basemap, `#7B5FB0` is the same ramp one step darker.

### 2. Lineage, not the whole tree

The layer shows ancestors ∪ self ∪ descendants. In a 1-2-3 chain that is everything, which
is the case that prompted the work. With branches it is not: viewing 2a, a premise held only
by sibling 2b does not appear unless their shared parent holds it too.

That is the same lineage the `is_valid` rule uses, so the three styles cover it exhaustively
with nothing left over — a sibling's premise would be neither above nor below you and would
need a fourth category.

**This may need expanding after testing (Dave, 2026-08-31): 2b might be adjacent to 2a, and
the point would be to flag 2b's premises so 2a can see them.** Worth being clear about what
that is, because widening the lineage to the whole tree would only half-answer it — an
unrelated project over the fence is exactly as invisible as a sibling, and it is the more
common case. So the want is adjacency, and hierarchy is a proxy for it that happens to catch
some of the instances. Logged in `docs/improvement-opportunities.md` rather than guessed at
here.

### 3. Correlated, so the view stays filterable

`projects.vw_project_premises_map` drives off `projects.projects` with a `CROSS JOIN
LATERAL`, so `WHERE project_id = ?` lands on the primary key and the lineage is resolved for
that one project.

The set-based alternative — resolve every project's lineage, let the caller filter — is the
shape migration 048 removed and 058's header forbids. A `WHERE` on a grouping column of an
aggregate that has already been planned cannot reach the base tables, so every layer fetch
would walk the recursive ancestor view once per project in the database.

The consequence is stated on the view's comment so it is not discovered later: **this view is
right behind a project filter and wrong for an estate-wide question.** `vw_project_premises`
and `vw_premise_claimants` are the views for that. The one deliberate unfiltered read is the
migration's own verify block, paid once.

### 4. Off by default

`map_layer_page_config.visible = false` for `projectedit`. OpenLayers only fetches a vector
source once the layer is first rendered, so an unticked layer costs the editor nothing on
load — the fetch happens on the first tick. Measured sizing from the premises build is ~50k
premise rows across 862 projects, so the payload is small in the ordinary case; the reason
to default it off is that it is a look-at-it layer, not a work-on-it one, and the editor map
already opens with seven layers live.

It is a per-page override, so it can be turned on in Admin → Map Layers without a migration.

### 5. Not editable, anywhere

`layer_editable`, `layer_allow_create` and `layer_allow_delete` are all false. Premises are
resolved from the boundary by `projects.refresh_project_premises()` and there is nothing on
this layer a user could correctly move, add or delete — the way to change what is on it is to
change a boundary.

## Build

### Phase 1 — The view and the layer (migration `063_project_premises_map_layer.sql`)

`projects.vw_project_premises_map`, one row per premise per viewing project:

| Column | Purpose |
|---|---|
| `project_id` | the **viewing** project — the filter key the editor sends |
| `company_id` | the viewing project's, so the view behaves under `map_get_v2.php`'s no-`project_id` branch |
| `uprn`, `address`, `postcode`, `geom` | from `basedata.vw_abp_min` |
| `premise_level` | the style key: `this_project` / `sub_project` / `parent_project` |
| `hierarchy_level` | the same fact in words — This Project / Sub-project / Parent Project |
| `counted_project_id`, `counted_project` | which project counts this premise, NULL when two branches both do |

The lineage is `projects.vw_project_ancestors` read in both directions inside the LATERAL —
`ancestor_id = me` gives me and everything below, `project_id = me` gives me and everything
above — carried as a signed `levels_below` (0 self, >0 below, <0 above).

`counted_project_id` is NULL where the lineage holds more than one valid claimant, the same
no-tie-break rule as `vw_premise_claimants`: there is no single owner to name and an
arbitrary `first()` would read as an answer. It counts `DISTINCT holder_id` because a
self-parent or an `A -> B -> A` cycle can put one project in both halves of the lineage
union — the path guard in `vw_project_ancestors` stops that recursing but not
double-counting.

`is_deleted` is filtered on the viewing project only, following 062: a map view filters
itself, so the rule is true for GeoServer and any other reader. The lineage needs no such
filter — a project must be hierarchy-free to be deleted, so a deleted project can never be
somebody's ancestor or descendant, and its own premises are tombstoned by the refresh
either way.

Registration: `layer_name = projectPremises`, `layer_category = 'project'` (the **Project
Layers** group in the editor's layer panel), `vectorPoint`, `z_index` 12 — above the
boundary and the neighbour outlines, below the plan network, since premises are the context
you plan against. `label_field` is empty on purpose: addresses at building scale are
unreadable as labels wherever more than a handful are on screen.

### Phase 2 — Point styles and popup copy

`pointStyleManager.projectPremiseStyles` in `www/js/map_layer_utils.js`, the three entries in
Decision 1. **Required, not optional**: `project_edit_v2.js`'s private `getPointStyle` does
`pointStyleManager[key][type]` with no guard, so a missing style key throws and takes the
whole editor map down, and a value that does not resolve falls through to `pointStyleDefault`
— which has fill and stroke but no `image`, so those points render *invisibly* rather than
wrongly. That is the failure migration 043 walked into.

`premise_level` is added to `excludedProps` in `project_edit_v2.js`'s feature popup, so
clicking a premise shows `Hierarchy Level: Sub-project` rather than the raw style token
beside it.

Migration **064** landed here after testing, when `sub_project` moved off the teal: 063 is
applied and its tooltip named that colour, and the tooltip is the layer's only legend. See
*Tested, and what changed*.

### Phase 3 — Documentation

This document, the Project Premises section of `CLAUDE.md`, and the adjacency item in
`docs/improvement-opportunities.md`.

## Testing checklist

1. Migration 063 applies cleanly on a database at 062, and re-running it fails on the guard.
2. Admin → Map Layers lists **Project Premises**, category `project`, pointing at `projects.vw_project_premises_map`, active.
3. The project editor's map tab shows **Project Premises** under *Project Layers*, unticked, with its tooltip.
4. No premise fetch happens on editor load — `fn/map_get_v2.php?geotable=projects.vw_project_premises_map` appears in the network tab only after the layer is ticked.
5. Ticking it on a standalone project (no parent, no children) draws every premise in the boundary, all in the solid purple `this_project` style.
6. That count matches the Premises tab's **This Project** figure for the same project.
7. Untick and re-tick redraws without error, and no console errors appear at any point.
8. On a project with children, the premises counted at a child draw in the smaller, paler `sub_project` purple, clearly distinct from the solid `this_project` purple beside it and not reading as green against the basemap; the solid ones match the parent's own **This Project** count.
9. Opening the child of that pair shows the same premises in purple, and any premise counted at the parent alone in the hollow grey `parent_project` style.
10. In a three-deep 1-2-3 chain, opening **any** of the three shows all three projects' premises — no premise in the chain is missing from any of the three views.
11. Opening the middle project of that chain does not style its own counted premises as `parent_project` — the top project's premises are the grey ones, not its own.
12. Clicking a premise shows Layer: Project Premises, Uprn, Address, Postcode, Hierarchy Level and Counted Project — and does **not** show a `Premise Level` line.
13. Counted Project names the project the style implies: the viewing project for solid purple, the child for the paler purple, the ancestor for grey.
14. A premise claimed by two branches under the same parent shows Counted Project empty rather than picking one.
15. There is no Edit link on a Project Premises feature in the popup, and no attribute form or draw control appears for the layer.
16. Editing the project boundary, saving, and re-ticking the layer shows the premise set has followed the new boundary.
17. Setting a parent on a previously standalone project and reloading changes the styles on the parent's map without a boundary edit.
18. Premises draw above the boundary polygon and below plan cables/structures/equipment where they overlap.
19. The layer is empty, not broken, for a project whose boundary contains no premises.
20. A project the user's company is not assigned to returns an empty layer rather than another company's premises.
21. Opening a project with a large boundary (the biggest in the estate) ticks on in acceptable time, and the editor stays responsive.
22. A deleted project's premises appear nowhere: restore it, and they come back on its own map and on its lineage's.

## Risks

- **The style key is the weak joint.** `vector_style` / `vector_style_field` in the database
  and `projectPremiseStyles` in `map_layer_utils.js` have to agree, and nothing checks it —
  a mismatch is either a thrown exception or invisible points. Migration 063's verify block
  asserts the SQL half (that `premise_level` only ever produces the three keys); the JS half
  is assertable only by looking.
- **Unfiltered reads of the view are expensive by design.** The correlated shape is right for
  the layer and wrong for anything estate-wide. The view comment says so; a future consumer
  that ignores it gets a lineage resolution per project.
