1// Which legend row the pointer is over.2//3// The carried stylesheet reveals two controls on rest -- the isolate4// crosshair (`assets/css/layer-header.css`) and the label-policy toggle5// (`assets/css/category-rows.css`) -- and it reveals them under6// `.category-row.is-hovered` / `.layer-header.is-hovered` rather than under7// `:hover`. Nothing in the clean-room build ever wrote that class, so both8// controls sat at `opacity: 0` for the whole life of the page: implemented,9// wired to their routes, and unreachable by pointer.10//11// THE CLASS IS NOT A REPLACEMENT FOR `:hover`, IT IS A CORRECTION OF IT. The12// reference tracked the pointer's position on purpose, and the CSS says so in13// a comment that names this function. A list scrolls under a still cursor --14// the wheel over the legend, a swap that shortened a section, the keyboard --15// and the browser does not reliably revise `:hover` when the ground moves16// under a pointer that did not. The row that was under the cursor kept17// offering its actions to a pointer that had long since left it. Asking where18// the pointer actually is answers correctly in both cases, which is why the19// answer is recomputed on scroll as well as on movement.20//21// WHY THIS LANE. Where the pointer is is continuous, ephemeral interaction22// state (issue #5 ยง4.1): it survives no swap, belongs in no session, and23// posting it would be posting a mouse. The application renders the rows and24// owns the stylesheet that reads the mark; this lane writes the mark, the way25// it wires the zoom buttons and the navigator's field without owning either.26//27// WHAT A SWAP DOES TO IT is the whole of the mechanism below. `#atlas-legend`28// is an `outerMorph` region, so every filtering move can replace the list29// whole -- and htmx's morph *preserves* a node whose id matches, so it can30// also hand back the very same container, and rows carrying the mark they31// wore before the swap. One rule covers both: every pass clears every mark in32// the container before it writes one, and a rescan is a pass.3334/**35 * The three things a mark may land on: a collection's row, a section's head,36 * or a row of a feature index -- which reveals an action of its own (ask after37 * this ground and no other) and is therefore the same kind of thing to the38 * pointer, however differently it is built.39 */40const ROWS = ".category-row, .layer-header, .zone-index-row";4142/** The class the carried stylesheet reads. */43const MARK = "is-hovered";4445/**46 * The pointer over the legend, and the row it is over.47 *48 * The container is a selector rather than an element, for the same reason the49 * scene node is (`scene/observe.ts`): a swap replaces it, and what stays true50 * across the swap is the name, never the node.51 *52 * A stopped tracker is spent -- the controller it registered everything53 * against cannot be re-armed -- so an element coming back to the page mints a54 * new one, exactly as it re-wires its keyboard.55 */56export class RowHover {57 private readonly selector: string;58 private readonly leaving = new AbortController();59 // THE SET IS PER TRACKER, and that is the difference between this and the60 // viewport's module-level `wired`. There, a listener is never taken off, so61 // the fact that a control has one has to outlive every element. Here the62 // listeners leave with the tracker, so a container remembered past its63 // tracker's life would be a container nobody is listening to -- the seam64 // reconnected, the legend still and dead. Weak either way, so a list that65 // leaves the page leaves this with it.66 private readonly wired = new WeakSet<Element>();67 private pointerAt: { x: number; y: number } | null = null;6869 constructor(selector = "#layers") {70 this.selector = selector;71 }7273 /** Wire whatever is under the selector now. */74 start(): void {75 this.rescan();76 }7778 /**79 * The after-swap hook: wire the container on screen, and say again what is80 * true of it.81 *82 * Both halves matter. A swap that replaced the list hands back a container83 * this has never listened to; a swap that morphed it in place hands back the84 * same container carrying marks from before, on rows that may not even be85 * under the pointer any more. The mark pass is unconditional for the second86 * case, and it runs before the wiring guard so it also covers the first.87 */88 rescan(): void {89 const layers = this.layers();90 if (!layers) return;91 this.mark();92 if (this.wired.has(layers)) return;93 this.wired.add(layers);94 const { signal } = this.leaving;95 layers.addEventListener("pointermove", (event: PointerEvent) => {96 this.pointerAt = { x: event.clientX, y: event.clientY };97 this.mark();98 }, { passive: true, signal });99 layers.addEventListener("pointerleave", () => {100 this.pointerAt = null;101 this.mark();102 }, { signal });103 // Passive, and deliberately not throttled: the work is one hit test and a104 // class, and a mark that lands a frame late is a control that flickers on105 // a row the reader has already left.106 layers.addEventListener("scroll", () => this.mark(), { passive: true, signal });107 }108109 /** Give the listeners back. A stopped tracker marks nothing again. */110 stop(): void {111 this.pointerAt = null;112 this.leaving.abort();113 }114115 /**116 * Clear every mark in the list, then mark the row the pointer is over.117 *118 * Clearing first is what makes this idempotent, and idempotence is what119 * makes it safe to call from a swap: however the page arrived at the marks120 * it is wearing -- a previous pass, a morph that carried them across, a row121 * that moved out from under the cursor -- the answer afterwards is the one122 * the pointer's own position gives.123 *124 * The containment check is not redundant with the listener's scope. The hit125 * test is asked of the whole document, and a menu, a card or the dock drawn126 * over the legend answers it with an element of its own; only a row this127 * list actually holds may be marked.128 */129 private mark(): void {130 const layers = this.layers();131 if (!layers) return;132 for (const marked of [...layers.querySelectorAll(`.${MARK}`)]) {133 marked.classList.remove(MARK);134 }135 const at = this.pointerAt;136 if (!at) return;137 const under = document.elementFromPoint(at.x, at.y);138 const row = under?.closest(ROWS) ?? null;139 if (row && layers.contains(row)) row.classList.add(MARK);140 }141142 /** The list as it is on the page now, never as it was when this was made. */143 private layers(): HTMLElement | null {144 return document.querySelector<HTMLElement>(this.selector);145 }146}