# Planner Topology Rebuild

- **Date:** 2026-09-28
- **Status:** Complete
- **Status Date:** 2026-09-29
- **Phases:** 4
- **Phases Complete:** 4
- **Notes:** Phase 7 of `docs/2026-09-09-planner-module-update.md`, split out because that
  document's Phase 7 section predates the port and connection model and is wrong about the
  starting point in two ways: it calls the work a rebuild of `prospector.network_topo_*`, which
  migrations 074 and 079 already moved to `planner.vw_topo_point` / `vw_topo_link`; and it was
  written before there was anything to draw beyond element-to-element. **The views exist and
  work. What is wrong with them is narrower and more specific than "rebuild".**

  Scope settled with Dave 2026-09-28: **element-grained**, nodes from elements, edges from
  cables, with connections informing the edges. Fibre trace and the capacity SLD are features in
  their own right and are logged in `docs/improvement-opportunities.md` — they are not this.

## Decisions taken before building

Both were confirmed with Dave on 2026-09-28, before Phase 1 was written.

**Edge per cable, not per continuity path** (the Phase 2 question). Continuity is a *fibre*-level
fact and cables are spliced partially — some fibres through to the next cable, some onto a
splitter, some spare, all at the same closure. Migration 103's own worked example is that case.
So an edge per continuity path is either one edge per fibre (96 edges where there is one cable)
or a merge threshold that hides the partial picture. An edge per cable also keeps an
`element_id` on every edge, which is what makes the schematic a way into the records. End-to-end
continuity is the fibre trace: a highlight *over* this graph, logged separately.

**Nodes are equipment, plus only those structures a cable actually ends at.** An SLD carries
cables and equipment — "I've never seen a chamber on one of these diagrams" (Dave). The seeded
catalogue already agrees: CMJ, the splitters, DJ, FDN, PBO, L0 and MM are `equipment`; CAB, JB
and SB are `structure`. But 105 allows a cable to end at a structure, and excluding structures
entirely re-creates the silent drop Phase 2 exists to kill. So a chamber housing a closure never
appears — the cable ends at the closure — and one a cable terminates at does, which is a
planning gap worth seeing rather than furniture.

## Plan Phases

1. ✅ Nodes: the structures a cable ends at, as well as equipment
2. ✅ Edges: a cable is not lost because it ends at a chamber
3. ✅ Classes from the registry, not from string-mangling
4. ✅ Delete the three dead variants

## Problem

The schematic reads `planner.vw_topo_point` and `planner.vw_topo_link` through
`fn/network_topo_get.php`, and draws them with Cytoscape in `networkTopology()`
(`project_edit_v2.js:7983`). Three things are wrong with it, and one thing that was expected to
be wrong is not.

### Nodes are equipment only

`vw_topo_point` selects from `planner.vw_elements_equipment` twice — once for the network head,
once for everything else. **No structure appears at all.** A chamber, a cabinet and a splitter
box are all invisible. Since migration 105 a cable may legitimately terminate at either kind, so
this is now a hole rather than a simplification.

Corrected while building Phase 1: an earlier draft of this section said a CMJ closure was
invisible too. It is not — the seed catalogue (072) puts CMJ, CMJ(1:4), CMJ(1:8), the splitters,
DJ, FDN, PBO, L0 and MM under `element_kind = 'equipment'`, and only CAB, JB and SB under
`structure`. The closures an SLD draws were never missing. **What is missing is only civils** —
which matters less as a diagram and more as the reason the edge below vanishes.

### An edge vanishes if either end is a structure

`vw_topo_link` inner joins **both** cable ends to `vw_elements_equipment`:

```sql
   FROM planner.vw_elements_cable c
   JOIN planner.vw_elements_equipment ea ON ea.element_id = c.a_end_element_id
   JOIN planner.vw_elements_equipment eb ON eb.element_id = c.b_end_element_id;
```

A cable between two chambers is absent from the schematic entirely. It is not drawn badly, it is
not drawn — and nothing in the UI says a link is missing, so the diagram reads as complete while
omitting whatever terminates at civil infrastructure. This is the defect decision 9 of the main
plan doc anticipated, and it is the most serious of the three because it is silent.

### The Cytoscape class is string-mangled

`vw_topo_point` derives its `classes` column by nesting four `replace()` calls over `type_key`:

```sql
COALESCE(replace(replace(replace(replace(type_key, '-', '_'), ':', '_'), '(', '_'), ')', '_'), 'UNK')
```

So `CMJ(1:8)` becomes `CMJ_1_8_`, and the Cytoscape stylesheet in `project_edit_v2.js` carries
those mangled strings as selectors. **An unmatched class renders an unstyled node rather than
throwing**, so a type renamed in admin silently loses its styling and nobody finds out until
somebody looks at a diagram and thinks it looks odd. Type keys are admin-editable, which makes
this a question of when rather than whether.

### What is NOT wrong: the `'L0'` literal

The main plan doc says the head-end node branches on `point_type = 'L0'`. It no longer does —
079 rebuilt the view around `is_network_head`, which is a real flag on the type with a unique
index behind it. That half of Phase 7 is already done and needs no work.

## Root-cause analysis

All three defects have one cause: **the views were ported from `prospector` faithfully rather
than reconsidered.** Migration 074 renamed the sources and 079 rebuilt the view bodies to read
`planner.elements`, but both preserved the original shape — and the original shape came from a
data model where `network_points` was the only node table and equipment was the only thing in
it. There were no structures to include, so the view did not include them; there was no port
model, so an edge could only be element-to-element.

The port and connection work has since made the richer diagram possible, which is why this is
worth doing now rather than at the time.

## Build

### Phase 1 — Nodes: the structures a cable ends at, as well as equipment ✅

**Built and tested 2026-09-28.** `db/107_planner_topo_nodes_structures.sql`,
`fn/network_topo_get.php`, `js/project_edit_v2.js`.

On project 9: 37 equipment and **7** structures, each with between one and four cables ending at
it and each appearing exactly once — so the `EXISTS` holds against a chamber with four cables.
Six structures were correctly left off, two CABs among them. `project_id` reaches
`planner.elements` through `elements_project_kind_category_idx` in all three branches; 4.8ms, no
JIT. Two findings came out of the testing and are in `docs/improvement-opportunities.md`: the
`OR` in the structure branch cannot use the cable-end indexes (fold into Phase 3, which rebuilds
this view anyway), and an isolated node does not reload where it was left.

`planner.vw_topo_point` gains a third branch. `CREATE OR REPLACE`, not a drop and re-create,
because the new column is appended at the end.

- **All equipment, as before**, keeping the `is_network_head` split — 079 already moved that off
  the `'L0'` literal onto a real flag.
- **Plus a structure where, and only where, a cable ends at it.** A correlated `EXISTS` over
  `a_end_element_id` / `b_end_element_id`, not a join: two cables ending at one chamber must give
  one node, and a duplicated id is dropped by Cytoscape without a word. Both columns are indexed
  (067).
- **`element_kind` is returned as a column**, and a kind token is prefixed to `classes` —
  `equipment CMJ`, `structure JB`. Cytoscape reads `classes` as a space-separated list, so every
  existing selector still matches and no equipment node changes appearance.
- **The `replace()` mangling stays** in this phase. Phase 3 replaces it together with the
  stylesheet, because splitting those two leaves every node unstyled without throwing.
- **`node.structure` added to the stylesheet** in the same change, last in the sheet so it wins
  over any type selector a structure shares a key with. Drawn as a dashed outline rather than a
  filled symbol, so it reads as the gap it is.
- **Positions need nothing.** A node with no `sld_x` / `sld_y` is placed by the `fcose` layout and
  keeps its own position once arranged and saved (Dave, 2026-09-28) — so there is no pile at
  0,0 and no reason to seed from `geom`. The question this section used to carry is closed.

**Expected while Phase 1 stands alone: the new structure nodes are unconnected.** `vw_topo_link`
still inner joins both cable ends to equipment, so the cable that caused a chamber to appear is
still not drawn — the node arrives with no edge on it. That is Phase 2, not a fault in Phase 1,
and it is worth knowing before testing so a floating chamber does not read as a bug. It also
means these nodes are disconnected as far as `fcose` is concerned, so they will be laid out
apart from the graph rather than beside the cable they belong to.

### Phase 2 — Edges: a cable is not lost because it ends at a chamber ✅

**Built and tested 2026-09-28.** `db/108_planner_topo_links_and_containment.sql`,
`fn/network_topo_get.php`, `js/project_edit_v2.js`.

**The edge runs to the cable's own end. Nothing is resolved through a splice.** `source` and
`target` are `a_end_element_id` / `b_end_element_id` with **no join to either end**, so nothing
about an end can remove the row. Every label comes from the cable itself.

**A design was written and rejected in between, and it is worth recording.** It resolved the
endpoint through the connections: if the cable was spliced to exactly one piece of equipment at
that chamber, the edge would run to the equipment instead of to the chamber. Dave stopped it
(2026-09-28) for the reason that kills it — **a structure houses more than one piece of
equipment**, so "the equipment it is spliced to" has no single answer and the rule needed an
arbitrary fallback for the case that is actually normal. It also drew one chamber as two nodes:
on project 9, CH-C is the B end of a spliced cable and the A end of an unspliced one, so per-end
resolution put the first on the equipment inside CH-C and the second on CH-C itself — two cables
terminating in the same hole in the ground, drawn apart, with nothing saying they were the same
place. **Containment is drawn instead**, which is what a hand SLD does: a box for the chamber
with the closure and the splitter inside it.

- **Containment is Cytoscape compound nodes.** `vw_topo_point` gained a `parent_id`, set on
  equipment housed in a structure **that is itself a node**, NULL everywhere else. Keyed on the
  parent being a structure, never on `parent_relation`, whose values come from
  `planner.category_relations` and can be renamed by an admin — 103 takes the same view, that
  `parent_element_id` is the fact and the relation is a label on it. Structures are never nested
  in structures, deliberately.
- **Which structures are containers: only where a cable ends** (Dave, 2026-09-28). A chamber
  housing a closure but with no cable ending at it stays off the diagram and that closure sits at
  top level, as it does today. The alternative — a box wherever a structure houses shown
  equipment — was declined as more on the page than it earns.
- **`planner.vw_topo_structure_node` is the single definition of which structures are nodes**, and
  both readers use it: the structure branch of `vw_topo_point`, and the `parent_id` subquery.
  **A compound parent missing from the graph is a dangling reference**, so two copies of the rule
  that drifted would produce one. The probe asserts the invariant over the whole view rather than
  trusting that.
- **It requires the cable's other end to be set.** The endpoint filters `source > 0 and target >
  0`, so a cable with one end unset is never drawn — and a structure whose only cable is that one
  would be a node with no edge. Mirroring the endpoint's filter is what stops an orphan chamber.
- **Per-end counts come from `element_connections`, not `vw_splice_schedule`**, as decided above,
  by `count(DISTINCT p.port_id)` — a port can match both sides of the normalised pair, and a
  fibre joined at one location is one splice however the row is shaped. Fibre count is
  `element_ports` where `port_group = 'fibre'`.
- **Everything is a correlated `LATERAL`**, so `WHERE project_id = ?` still reaches
  `planner.elements`. No CTE, no set-based aggregate.
- **The label gained the two facts it lacked:** `name | type | 48F | A 24 / B 12 | 120m`, and
  `unspliced` in place of two zeros, because nothing-at-either-end is a state worth naming
  rather than a pair of numbers to read.
- **Phase 3's index fix landed here**, since this migration rewrote the branch anyway: 107's
  single `EXISTS` with `(a_end = ... OR b_end = ...)` is now two `EXISTS`, one per end column, so
  each can use `elements_a_end_idx` / `elements_b_end_idx`.
- **A compound parent has no position of its own** — Cytoscape derives its box from its children,
  so `networkTopologySave()` now skips parents rather than writing a computed value into
  `sld_x` / `sld_y` where it would look authoritative. Children save exactly as before, as does a
  chamber housing nothing.

### Phase 3 — Classes from the registry, not from string-mangling ✅

**Built and tested 2026-09-29.** `db/109_planner_topo_styles_from_registry.sql`,
`fn/network_topo_get.php`, `js/project_edit_v2.js`.

**The mangling is gone and nothing replaced it.** `classes` no longer contains any
admin-editable text — it is `<element_kind> [head] [nostyle]` and nothing else. Shape, fill and
stroke are returned as data and applied through Cytoscape's `data()` mapping, so **there is no
per-type selector left in the stylesheet at all**. A type renamed in admin keeps its styling
because nothing keys on its name any more.

- **`planner.vw_type_effective_style` is new** and answers "what does this type look like" once:
  its assigned `map_styles` row if it has an active one, otherwise the unknown style for its
  geometry kind. **The fallback is a whole style, not a per-column default** — written as
  `COALESCE(s.stroke_dash, fb.stroke_dash)` a resolved *solid* line has `stroke_dash` NULL, which
  is meaningful, and would silently borrow the fallback's dash and draw dashed.
- **`style_resolved` is not "the style is not pt_unknown".** 076 assigns `pt_unknown` to the
  `unspecified` types deliberately, and those are styled as intended. The flag says whether the
  lookup found anything, never what it found. A type whose style has been *deactivated* falls
  back too, matching `fn/planner_styles.php`.
- **The two equipment branches collapsed into one.** They differed only in the `classes` string;
  the head end is a token now. `is_network_head` still decides it.
- **Cytoscape has no `circle`, it has `ellipse`.** The symbol→shape translation sits in
  `vw_topo_point`, not in the style view, which stays renderer-neutral because the map reads the
  same names.
- **A structure takes its colours from the library but not its shape.** Once it houses equipment
  it is a compound parent sized from its children, so there is no symbol to draw — and styling
  the leaf case as a symbol and the parent case as a box would make one chamber look like two
  different kinds of thing depending on whether anything had been recorded inside it yet. So
  `round-rectangle` is **the one style literal left in the view**, and it is a renderer
  constraint rather than an opinion about a type. The colours are the type's own: the box is
  tinted with its `fill_colour` at low opacity and outlined in the same colour, because a
  container is a backdrop for what sits in it.
- **That colour part was a late fix (Dave asked, 2026-09-29).** The branch first hardcoded
  `#ffffff` / `#6b6b6b`, which drew CAB, JB and SB identically grey while the map already tells
  them apart — dark green square, grey square, brown triangle — so the schematic lost *that is a
  cabinet, not a chamber* at a glance for nothing. The probe now asserts every structure shows
  its type's library colour, which is what stops the container branch quietly reacquiring a
  palette of its own.
- **Nothing copies a style.** No `INSERT` or `UPDATE` against `planner.map_styles`, no second
  copy of any value, and the fallbacks are looked up by key out of the same table. Styles resolve
  by live join, so editing a library row in admin changes the schematic on its next load with
  nothing to regenerate.
- **The fallback now looks like a fallback**: a `nostyle` token drawing a thick red dashed
  border, last in the sheet so it wins. The old `'UNK'` was indistinguishable from a real style
  nobody had noticed.

**Beyond the plan's letter, flagged for a decision: cable colours.** 076 gave every fibre count
its own line style precisely so the count reads off the drawing, and the schematic drew every
cable as the same 1px black line. `vw_topo_link` now returns `stroke` / `stroke_width` /
`stroke_dash` and the edge rule maps them. It is the same "read the library" change applied to
the other half, and leaving the edges flat black while the nodes come from the registry would
have been a half-migration — but it is an addition rather than something the plan asked for, and
it is a one-line revert if the heavier coloured edges read badly. The registry dash is **not**
reused to mean `unspliced`; the label carries that, or one visual channel would have two
meanings.

**The two libraries had drifted, and aligning them changes what you see.** This is the point of
the change — the map is canonical and recolouring a type becomes an admin action — but it is
visible:

| Type | Map (now used) | Schematic (before) |
|---|---|---|
| MM | grey **square** | grey **diamond** |
| DJ | orange **diamond** | orange **square** |
| 1:32 | **cyan** pentagon | **pink** pentagon |
| 1:8 | **amber** hexagon | **violet** hexagon |
| 1:4 | **pink hexagon** | violet-blue **octagon** |

MM and DJ had their shapes swapped between the two. CMJ, CMJ(1:4), CMJ(1:8), FDN, PBO and the L0
head end already matched and are unchanged.

#### Phase 3 follow-up — the schematic stops reading dynamic fields

**Built 2026-09-29, awaiting testing.** `db/110_planner_topo_drop_dynamic_labels.sql`,
`fn/network_topo_get.php`, `js/project_edit_v2.js`. Asked for by Dave after testing Phase 3.

Four columns went, and they were the last thing in the schematic reaching around the
meta-driven element model into named EAV keys — the same class of coupling Phase 3 removed from
styling, where an admin rename silently emptied a label rather than erroring.

- `vw_topo_point`: `point_address`, `point_fibre_allocation_details`
- `vw_topo_link`: `pianoi_ref`, `fibre_allocation`

**A DROP and CREATE, not a REPLACE** — `CREATE OR REPLACE VIEW` can only append a column. Safe
because `fn/network_topo_get.php` is the only consumer and no view depends on either.

**`planner.element_text_value()` is now gone from both views entirely**, where it ran up to twice
per node and three times per cable on every load.

**The cable label.** It read `Customer Test 144F | 144F | 144F | unspliced | 1207m`. Segment 1
was the `customer_ref` dynamic field, now the element's own name. Segments 2 and 3 were
`c.type_key` and the computed `fibre_count` — two genuinely different facts that read as a
stutter because the catalogue names cable types after their fibre count. **The computed count is
kept and the type key dropped**: the count is the number of `port_group = 'fibre'` ports the
element actually has, so it stays true when a cable is topped up (101) or retyped, and it is the
denominator the A/B splice numbers are counted against. The type key is already carried visually
by the edge colour, which comes from that type's own style. `concat_ws` replaced `concat`, so a
cable with no length drops that segment instead of ending in a bare `m`.

It now reads `Customer Test 144F | 144F | A 24 / B 12 | 1207m`.

**On the JS side the popper labels went with them.** `createLabels()` is down to one block, the
cable label; the node address popper, the node fibre-allocation popper, the edge PIA/NOI popper
and the edge fibre-allocation popper are all gone, along with their arrays, their update and
toggle functions, and four now-unreachable CSS rules. **A node still shows its name** — that is
the Cytoscape label in the middle of the symbol, not a popper, so nothing was lost there.
Everything was edited inside the live function's slice only; the three dead variants keep their
copies until Phase 4.

**Surfacing chosen fields deliberately, in preset slots, is logged in
`docs/improvement-opportunities.md`** rather than done here — keyed on `field_id` rather than
field name, so a rename changes the text and breaks nothing.

### Phase 4 — Delete the three dead variants ✅

**Done and tested 2026-09-29.** `js/project_edit_v2.js`, `fn/network_topo_get.php`.
1,028 lines out of the JS (11,036 → 10,008) and 30 out of the endpoint.

**They were already commented out, which the plan did not know.** Lines 6951–7334 were one
`/* */` block holding `networkTopologyOld()` *and* `networkTopologyv2()`, and 7391–7978 a second
holding `networkTopologyPrev()`. So none of the three was live JS, and every earlier note in this
document about them "carrying the old mangled selectors" was describing commented-out text. The
deletion was correspondingly lower-risk than planned — but the reason for doing it stands
unchanged and was in fact sharpened: **commented-out code greps exactly like live code**, which
is precisely how three copies of the auto-route teardown diverged during Phase 6, and how these
three kept surfacing in every search made during Phases 1–3.

**Two orphans went with them**, both found by the plan's instruction to look for siblings:

- `validateNetworkData()` (was 7337) — real, uncommented code with **no caller anywhere in
  `www/`**. It filtered the topo fetch for the three variants, so it was orphaned by the same
  removal.
- `$mockdata` in `fn/network_topo_get.php` — a 29-line array assigned at the top of the file and
  never read; its only two references were commented-out `echo`s. It also carried the stale
  vocabulary Phase 3 removed (`"classes" => "hexagon"` / `"octagon"` / `"square"`), so anyone
  reading the endpoint for what the view should return was being shown the wrong answer.

`docs/unusedfiles.md` needed no change — it has no topology entries, and both `network_topo_*.php`
endpoints are live and referenced.

## Testing checklist

- [X] Migration 107 applies cleanly (2026-09-28).
- [X] `planner.vw_topo_point` returns a structure that a cable ends at, as well as all equipment — 7 and 37 on project 9.
- [X] A chamber with no cable ending at it does **not** appear — six left off, two CABs among them.
- [X] Two cables ending at one chamber give one node, not two — held up to four.
- [X] Every equipment node looks exactly as it did before the change.
- [X] `EXPLAIN` confirms a `project_id` filter reaches the base tables in both views.
- [X] A cable between two chambers appears as an edge on the schematic — the case that was absent.
- [X] A cable between two equipment items still appears, unchanged.
- [X] A cable with one end unset does not appear, and the canvas's unlinked panel names it.
- [X] A chamber housing equipment draws as a box with that equipment inside it.
- [X] The box is sized around its contents rather than clipped to a fixed square — Cytoscape does ignore the base width/height for a compound parent.
- [X] A chamber housing nothing still draws as a plain dashed node.
- [X] Equipment whose chamber is **not** on the diagram still draws at the top level.
- [X] No node is nested inside a box that is not itself on the diagram.
- [X] Dragging a chamber box moves its contents with it.
- [X] Save positions, reload: equipment inside a box keeps its position, and a chamber with contents is unaffected by having no saved position of its own.
- [X] Every node on a real project is styled; none render as the unstyled fallback.
- [X] Rename a type in admin and confirm its nodes keep their styling.
- [X] A type with no style renders as an obvious fallback rather than looking like a styled node.
- [X] A type's symbol and colour on the schematic match the same type on the map.
- [X] MM and DJ have swapped shapes and the three splitter colours have changed — the map's version confirmed as wanted.
- [X] Recolour a type in admin, reload the schematic, and the node follows without a code change.
- [X] Cables draw in their fibre-count colour and width rather than 1px black.
- [X] A cable whose registry style is solid does **not** draw dashed.
- [X] The network head is still distinguishable from everything else.
- [X] Deactivate a style in admin and the types using it fall back visibly rather than silently.
- [X] A cabinet, a chamber and a splitter box are distinguishable from each other by colour, not all grey.
- [X] A chamber box is a faint tint of its own colour, and the equipment inside it stays legible through it.
- [X] No address, PIA/NOI or fibre-allocation label appears on any node or edge.
- [X] A node still shows its name.
- [X] The cable label reads `name | <n>F | A n / B n | length`, with the name being the element's own.
- [X] A cable with no length set drops the length segment rather than showing a bare `m`.
- [X] An unnamed cable reads `Unnamed <id>` rather than a bare `Unnamed`.
- [X] The schematic response is valid JSON — no PHP warning text in the body from a removed column.
- [X] Edge labels show the cable's fibre count and how many are spliced at each end.
- [X] A cable with no connections reads as unspliced rather than as an ordinary edge.
- [X] Drag a node, reload — the position persists, for a structure as well as for equipment. A node with **no edges** does not, which is pre-existing and now a backlog item.
- [X] `grep -n "networkTopology" www/js/project_edit_v2.js` returns only the live function, its save and their callers.
- [X] A user with planner read sees the schematic and cannot drag nodes.

## Risks

- **The stylesheet re-map is the one step that fails silently.** Changing the view's `classes`
  without changing the Cytoscape selectors leaves every node unstyled and throws nothing. Both
  halves go in one change, and the test list checks styling explicitly rather than assuming a
  clean render means a correct one.
- **Closed — node count.** The first draft would have added every structure, roughly doubling the
  count. Scoping to structures a cable ends at makes them rare by construction: on a
  well-modelled project most cables end at a closure, which is equipment. If a real project shows
  a lot of them, that is the data saying cables are terminating at civils, not the diagram
  needing a toggle.
- **Closed — new nodes have no saved layout.** `fcose` places them and they keep a position once
  arranged and saved (Dave, 2026-09-28). No seed from geometry needed.
- **Closed — the Phase 2 decision.** Edge per cable, confirmed 2026-09-28.
