# Wayleave ↔ Project — Map Layers

- **Date:** 2026-10-04
- **Status:** Complete
- **Status Date:** 2026-10-04
- **Phases:** 3
- **Phases Complete:** 3 of 3 — Phases 1 and 2 built and tested by Dave 2026-10-04; 19 of
  21 checks passed as written, test 2 (system flag, migration 114) and test 8 (popup
  heading) fixed and re-tested the same day, all passing; test 19 not runnable until
  wayleave delete exists
- **Notes:** The piece `docs/2026-09-30-wayleave-project-integration.md` missed. That plan
  put the wayleave ↔ project relation on two **tabs**; this one puts it on both editors'
  **maps**. Three read views over rows that already exist, three `public.map_layers`
  registrations, two style buckets. Nothing materialised, nothing written. The design
  questions were answered by Dave in conversation on 2026-10-04 and are recorded under
  **Decisions**; no prototype phase, because the layers follow 063's established shape and
  the one visual choice — hatch fills — was agreed on a code sample.

## Plan Phases

1. ✅ The three views and the three layers — migration `113_wayleave_project_map_layers.sql`
2. ✅ Hatch fills and ring points — `map_layer_utils.js`, popup exclusions in `wayleave_edit_v3.js`
3. ✅ Documentation — this document and the CLAUDE.md note

## Tested, and what changed

Tested on the dev server 2026-10-04 with 113 applied. 19 of 21 checks passed as written.
Test 19 could not be run: wayleave delete is not built (the backlog item from 2026-08-29),
so there is no deleted wayleave polygon to check against. The project half of it is covered
by 112's own filter and was not separately exercised.

**Test 2 — the layers were not protected as system layers.** 089 added `is_system` and
flagged every vector layer then present; `admin_save.php` refuses to delete a flagged layer
and locks its structural columns. 113 followed 063's INSERT shape, which predates 089 and
names no `is_system`, so all three rows took the default `false`. **Migration 114** sets the
flag and re-asserts 089's invariant that every vector layer is a system layer. The rule to
carry forward, now in CLAUDE.md: a migration that inserts a vector layer after 089 must set
`is_system = true` itself.

**Test 8 — the popup heading said "Feature 1", not the project name.** `wayleave_edit_v3.js`'s
popup computed `featureName` from `props.name` and then rendered `popupFeatureTitle`, the
"Feature N" fallback, in the heading. It had gone unnoticed because every wayleave layer
until now was the wayleave's own, with nothing to be named for; Affected Projects is the
first whose features are other records. The heading now renders `featureName`, escaped,
which falls back to "Feature N" where a feature has no name, so the five 037 layers are
unchanged.

**Found alongside, not caused by this work:** the global search's wayleave result linked to
`?do=wayleaveedit&agreement_id=N`, the pre-031 noun, which the v3 editor does not read, so
the search landed on an empty editor. Fixed in `main.js` to `&wayleave=N`, the parameter the
list page's links use. An attempt to fix it on the dev machine had instead lost the last 62
lines of `main.js` — the shared `wayleaveMatchReason()` / `wayleavePremiseFigure()` /
`pgBool()` formatters both editors' tabs call — so the file was restored from the last
commit before the one-line fix was applied.

## Problem

Since migration 112 a wayleave's Projects tab and a project's Wayleave tab read one view,
`wayleave.vw_wayleave_project_matches`, and agree about *which* projects a wayleave affects.
Neither map shows *where*. A planner reading "Smith Farm wayleave, matched by boundary and
premises, 42 premises" on the Wayleave tab has to switch to the wayleave editor to see the
polygon, and even there sees it over the wayleave's own context, not over the project
boundary being planned against. A wayleave officer opening a wayleave sees its polygons and
its UPRN points and nothing of the projects those encumber.

The data to draw both already exists on both sides. What was missing was the view that
joins geometry to the match, and the layer rows that put it on the right page.

## Decisions

All Dave, 2026-10-04, in conversation.

### 1. Both the polygon and the premises layer on the project side

A wayleave created from UPRNs alone, or from an attached stocklist or title, has no polygon
and must still be visible on the projects it affects. So the project editor gets two layers:
the wayleave **polygons** crossing its boundary, and the **premises** inside it under
approved coverage. Either alone misses one way a wayleave can exist.

### 2. The polygon that intersects, not the wayleave's whole footprint

A wayleave with three polygons of which one crosses the project boundary shows that one.
The question on the project map is where the encumbrance lies on *this* ground, not what
else the wayleave covers. This makes `vw_project_wayleave_polygons_map` simply 112's boundary
arm left unaggregated, so a polygon appears exactly when the Wayleave tab says
`matched_boundary` for that wayleave — by construction, not by coincidence.

### 3. The style is binary: complete or not

Not status, and not the status style token either. The token was the first proposal — the
view emits `wayleave_status.wayleave_status_style`, the registry defines one entry per token,
and admin controls the mapping with no migration. It is sound engineering and the wrong
answer for a map: seven statuses in seven styles lose their meaning at map scale. The person
in front of the map wants one thing — is this wayleave done or not — and has the Wayleave tab
for the rest.

So every row carries `map_state`, exactly `complete` or `incomplete`, and the registry has
two entries. On the wayleave side the same rule styles the affected **projects** by project
completion, which is the binary a wayleave officer needs about them.

### 4. The line between the two is the dashboard's complete status

`public.dashboard_config`'s `complete_status` row for the module, the admin-set status the
dashboard already uses to call a record finished. The views join it; nothing is hardcoded,
and changing it in Module Management → Dashboard changes the map on the next load.

**With no complete status configured, everything is `incomplete`.** That is the safe
failure: a planner is told to check, not told all is well. The tooltip names no status for
the same reason — it would go stale in a string.

### 5. Hatch fills, with the outline in the same hue

Complete is a single diagonal (`/////`) in green with a green outline; incomplete is a
cross-hatch (`XXXXX`) in red with a red outline. OpenLayers 10.1.0's `ol.style.Fill` takes a
`CanvasPattern` directly, so the hatch is an offscreen canvas tiled with `createPattern`,
no library. Two reasons for pattern *and* outline: at low zoom a polygon shrinks to a sliver
and only the stroke colour reads; at high zoom it fills the screen and the hatch does. And
`/` against `X` differs in form, which carries the pair under colour-vision deficiency — the
red/green pairing `2026-07-27-field-safe-colour-scheme.md` guards against is acceptable here
because the hatch does the work the hue cannot be trusted with.

Points are a **ring**, not a disc: radius 7, translucent centre, green or red stroke. The
layer sits directly over Project Premises and shares its UPRNs, so a filled point would hide
the purple premise beneath and with it the this/sub-project reading. With both layers on, a
premise reads "counted here, under a live wayleave" at a glance.

One thing to know about pattern fills: the hatch is anchored to the screen, not the map, so
it keeps its spacing at every zoom (wanted) and appears to slide under the polygon during a
pan (unavoidable, and unnoticeable once the pan stops).

### 6. Registered by migration, off by default, not editable

As 063 did. The `map_layers` and `map_layer_page_config` rows are seeded by 113 so every
environment gets them; `visible = false` is a per-page override that Admin → Map Layers can
flip without a migration. Nothing on these layers can be correctly moved, added or deleted —
each is a projection of something edited elsewhere.

## Build

### Phase 1 — The views and the layers (migration `113_wayleave_project_map_layers.sql`) ✅

Three views in the `wayleave` schema. Every one exposes the filter key its editor sends,
`company_id` for `map_get_v2.php`'s no-`where` branch, `name` (both popups read it as the
feature title), `map_state` and `geom`.

| View | Page | One row per | Geometry | `map_state` is |
|---|---|---|---|---|
| `vw_wayleave_project_map` | wayleaveedit | affected project | project boundary | the **project's** completion |
| `vw_project_wayleave_polygons_map` | projectedit | intersecting wayleave polygon | the polygon | the **wayleave's** completion |
| `vw_project_wayleave_premises_map` | projectedit | premise × covering wayleave | the premise point | the **wayleave's** completion |

`vw_wayleave_project_map` sits straight on 112 — both arms, so a polygon-less wayleave still
shows the projects holding its premises. `matched_by` carries which arm in the Projects tab's
words, for the popup. `wayleave_id` is a grouping column of 112's view, so the editor's
filter pushes through into both arms.

`vw_project_wayleave_polygons_map` is `projects.projects ⋈ ST_Intersects ⋈ wayleave_polygons`,
the boundary arm per polygon. Served repaired, not `raw_geom=1`: 051 made the wayleave
editor's own polygon layer raw because that layer is *edited*; this one is display-only, and
`map_get_v2.php` says the repair is the better outcome for display.

`vw_project_wayleave_premises_map` reads `project_premises` at `approved` and looks up
coverage through a `CROSS JOIN LATERAL (SELECT DISTINCT wayleave_id … WHERE uprn = pp.uprn
AND state = 'approved')` — correlated, so the `project_id` filter lands on the premises
index and the coverage lookup runs per premise against `wayleave_coverage_uprn_idx`. A joined
`DISTINCT` subquery would be planned once over the whole coverage table for every fetch
(058's rule). `DISTINCT` on `wayleave_id` because coverage is one row per premise per
*source*, and a premise claimed by a polygon and a stocklist is one covered premise under
that wayleave. No `is_deleted` filter on the project, for 112's reason: a deleted project's
premises are tombstoned by the refresh, and `state = 'approved'` already excludes them.
`hierarchy_level` is This Project / Sub-project from `is_valid`, for the popup; a
sub-project's premise is still encumbered ground inside this boundary and is drawn
(2026-09-30, decision 3).

Company scoping is in none of them, as with 112 — `map_get_v2.php` applies it per request
from the `where` key, project assignment on one side and wayleave assignment on the other.
That is the scoping Dave chose for the Wayleave tab: a planner who can open the project sees
the encumbrance with no wayleave permission needed.

Registration, three rows:

| `layer_name` | Title | Page | Type | `z_index` | `label_field` | `uid` |
|---|---|---|---|---|---|---|
| `wayleaveProjects` | Affected Projects | wayleaveedit, category `wayleave` | vectorPoly | 205 | `name` | `project_id` |
| `projectWayleavePolygons` | Wayleave Polygons | projectedit, category `project` | vectorPoly | 11 | `name` | `polygon_id` |
| `projectWayleavePremises` | Wayleave Premises | projectedit, category `project` | vectorPoint | 13 | *(empty)* | `uprn` |

z-order: on wayleaveedit the 037 layers are at 200 (polygons) and 210–213 (points), so
projects go between. On projectedit 063 put premises at 12; wayleave polygons go under them
at 11 and wayleave premises over them at 13, which is what the ring style is for.
`vector_style` is `wayleaveStateStyles` / `wayleaveStatePointStyles`, `vector_style_field`
is `map_state` on all three.

The verify block asserts the url / schema / table triple agrees on every row (025's lesson),
one page-config row per layer on the right page, every column the endpoint or the style path
needs on every view, and — one unfiltered read of each view, paid once — that `map_state`
produces only the two keys the JS defines.

### Phase 2 — Styles and popup ✅

`www/js/map_layer_utils.js`:

- `hatchPattern(stroke, spacing, kind)` — an offscreen canvas at `devicePixelRatio` (a 1×
  canvas tiled on a 2× display renders each hairline as a two-pixel smear), one or two
  diagonals, returned via `createPattern(canvas, 'repeat')`.
- `polyStyleManager.wayleaveStateStyles` — `complete` (green `/` hatch, `#0E6B62` stroke),
  `incomplete` (red `X` hatch, `#B00020` stroke), both with the standard label text style so
  `label_field` renders.
- `pointStyleManager.wayleaveStatePointStyles` — `complete` / `incomplete` rings, radius 7,
  `rgba(255,255,255,0.25)` fill, 2.5px stroke in the same two hues. No text.

Both editors already read `MapLayerUtils`' registries (project_edit_v2.js aliases them at
its top), so no per-editor change is needed for styling.

`www/js/wayleave_edit_v3.js`: `project_id` and `company_id` added to the popup's
`excludedProps`. The wayleave editor's popup lists every property, so the Affected Projects
popup reads *Project Status, Matched By, Premises Total, Premises This Project, Premises Sub
Project* under the project name. The project editor's popup is already one line per feature
(2026-09-22) and reads `props.name`, so the project-side layers need nothing there.

### Phase 3 — Documentation ✅

This document, and a paragraph under **Wayleave ↔ Project Matching** in `CLAUDE.md`,
including the `is_system` rule for future layer migrations.

## Testing checklist

1. Migration 113 applies cleanly on a database at 112, and re-running it fails on the guard.
2. Admin → Map Layers lists **Affected Projects** (category `wayleave`), **Wayleave Polygons** and **Wayleave Premises** (category `project`), all active, each pointing at its `wayleave.vw_*_map` view.
3. The wayleave editor's map tab shows **Affected Projects** under *Wayleave Layers*, unticked, with its tooltip; the project editor's map tab shows **Wayleave Polygons** and **Wayleave Premises** under *Project Layers*, unticked, with theirs.
4. No fetch of any of the three views appears in the network tab on either editor's load; each appears only after its layer is ticked.
5. On a wayleave whose Projects tab lists N projects, ticking Affected Projects draws N boundaries, labelled with the project names.
6. A wayleave defined only by a stocklist or title (no polygon) still draws the projects holding its premises.
7. A project at the projects module's complete status draws in green diagonal hatch with a green outline; any other project in red cross-hatch with a red outline.
8. Clicking an affected project shows its name as the title, then Project Status, Matched By, and the three premise counts — and no Project Id, Company Id or Map State line.
9. On a project whose Wayleave tab shows a wayleave matched by boundary, ticking Wayleave Polygons draws the polygon(s) that cross the boundary, labelled with the wayleave name — and not that wayleave's other polygons elsewhere.
10. A wayleave at the wayleaves module's complete status draws in green diagonal hatch; any other in red cross-hatch.
11. On a project whose Wayleave tab shows a wayleave matched by premises, ticking Wayleave Premises draws rings on those premises; the count of distinct premises ringed matches the tab's premise total for that wayleave.
12. With Project Premises also ticked, the purple premise disc is visible inside each ring.
13. A premise under two wayleaves at different statuses shows both rings, and the project popup lists both wayleaves at that point.
14. Changing the wayleaves complete status in Module Management → Dashboard and reloading the project editor moves wayleaves between green and red with no other change.
15. With no complete status configured for wayleaves, every wayleave polygon and ring is red.
16. Zoomed out until a polygon is a sliver, the outline colour still reads; zoomed in until it fills the screen, the hatch reads; the hatch keeps the same spacing at both.
17. Clicking a wayleave polygon or ring on the project map shows one line, *Wayleave Polygons · <name>* or *Wayleave Premises · <name>*, with no Edit affordance.
18. No console errors on either editor at any point, including ticking and unticking each layer repeatedly.
19. A deleted wayleave polygon (`is_deleted`) appears on neither map; a deleted project appears on no wayleave's Affected Projects.
20. A project the user's company is not assigned to returns empty layers rather than another company's wayleaves; a wayleave the user's company is not assigned to returns an empty Affected Projects layer.
21. The largest project in the estate ticks all three layers on in acceptable time, and the editor stays responsive.

## Risks

- **The style key is the weak joint**, as with 063. `vector_style` / `vector_style_field` in
  the database and the two buckets in `map_layer_utils.js` must agree; a polygon that misses
  falls to a visible grey dash, a point that misses renders invisibly. 113's verify asserts
  the SQL half; the JS half is asserted by looking.
- **"Complete" is admin-set and may be unset.** Every row then reads incomplete, by design,
  and nothing on the map says why. The admin matrix shows the module as unconfigured, which
  is the place that should say it.
- **The tooltips carry the legend.** "Diagonal" and "cross-hatch" are load-bearing words; a
  change to the hatch kinds needs a migration for the strings (064).
- **The premises view can be large.** A big project under a wide wayleave is one row per
  premise per wayleave; it is correlated and indexed and fetched only on tick, but it is the
  one of the three worth watching on the estate's largest project (test 21).
