doxa-maps/library/composables/usePplrInstance.js
doxa-maps-88655c70 6e958bc22f
Some checks are pending
build-gate / build (push) Waiting to run
legend: open on the UPG-100 list, which is what the Driver asked for first
Measured at the rendered layer: the legend opened on REGION at desktop and
mobile. That is the Driver's first complaint about this legend word for word —
"it shouldn't have me sitting there on Regions, I don't know what to do, it's
confusing" — and the card recorded that part as shipped.

Every source signal said it worked. LEGEND_DEFAULT_TAB = 1 existed, defaultTab
was a declared prop, it seeded a local ref, and it was named in a watch fallback.

The root cause is one layer down. SemanticTreeLegend resolves its tab as
`instance ? instance.activeTab.value : localActiveTab.value`, and
usePplrInstance initialises activeTab to ref(0). Zero is indistinguishable from
"the user chose tab 0", so for every profile sharing an instance store the
defaultTab prop was dead — read on every render, never able to win. An
activeTabTouched sentinel fixes it: the store only wins once something actually
chose a tab, meaning a tab click or a node selection that jumps to that node's
generation. A sentinel rather than a new default, so no other profile's landing
tab moves as a side effect.

And a regression of my own, from the previous commit. There was only one
:default-tab left in the profile and it was on <LegendMobile>, where defaultTab
is not a prop. Last tick I "dropped the inert attribute" with a string replace
that matched the DESKTOP block first, deleting the working one and leaving the
inert copy. A dropped attribute is silent, so the build stayed green. Restored
to SemanticTreeLegend, and LegendMobile now has a real defaultTab prop so the
phone honours it too.

Both widths open on "UPG-100 Lists", and on ?lang=es both open on "Listas
UPG-100" — locale-independent. A user's own choice still sticks: clicking Region
holds at 0, and selecting a row does not snap back, so the sentinel is not a
trap.

check:legend-export-reachable still passes and now selects a real zone rather
than a region, because the legend opens on the Lists tab. The two fixes compound:
a phone user lands on the lists and can export the one they pick.
2026-09-24 17:59:57 +00:00

113 lines
4.2 KiB
JavaScript

/**
* usePplrInstance.js — per-map "instance store" mediator.
*
* Ported verbatim from the upstream PPLR module
* per QA building-round-1 R4 decision: take their legend and instance pattern
* word-for-word, integrate into research-mfe.
*
* Implements the Mediator Pattern: each map cluster (one map + one geocoder +
* one legend) gets its own scoped store. Components never call each other
* directly — they read/write the store and watch its fields.
*
* The research-mfe ALSO has a Pinia mapStore. The SemanticTreeLegend's
* internal code reads/writes this instance; research-map.vue bridges
* instance.selection ↔ mapStore.selected{Family,Language,Dialect} so the
* existing applyDimFilter + geocoder paths keep working unchanged.
*/
import { ref, provide, inject } from 'vue'
const KEY = 'pplrInstance'
/**
* Create a new instance store for one map.
* @param {string} instanceId Stable id used for debugging/keying.
* @returns {object} reactive store
*/
export function createPplrInstance(instanceId) {
return {
instanceId,
// ── Selection (drives map filter + legend highlight) ────────────────────
/** Currently selected tree node, or null. Shape: { id, label, kind, depth, filter, color, ...extras } */
selection: ref(null),
/** Active legend tab index (0-based — but Tab 1 = Generation 1 in user-facing labels) */
activeTab: ref(0),
/** Has anything ACTUALLY chosen a tab yet — a user click, a search, a pin?
*
* ★ WHY THIS EXISTS. activeTab starts at 0, and 0 is indistinguishable from "the user
* picked tab 0". SemanticTreeLegend resolves its tab as
* `instance ? instance.activeTab.value : localActiveTab.value`, so for every profile
* that shares an instance store the `defaultTab` prop was DEAD — declared, passed,
* seeding a local ref, and named in a watch fallback, yet never able to win.
* MEASURED 2026-09-24: my-upg-100-list passes :default-tab="1" (the UPG-100 Lists tab)
* and opened on Region at BOTH widths, which is the Driver's first complaint about this
* legend verbatim — "it shouldn't have me sitting there on Regions, I don't know what to
* do, it's confusing."
* A sentinel rather than a different initial value: changing activeTab's default would
* move every other profile's landing tab as a side effect.
*/
activeTabTouched: ref(false),
/** Search-mode flag: true when geocoder text ≥ 2 chars */
isSearching: ref(false),
// ── Geocoder text (read by legend so it can dim itself, etc.) ────────────
geocoderText: ref(''),
// ── Pin-detail panel (clicked pin replaces legend with detail) ──────────
/** Currently clicked pin properties, or null */
pinDetail: ref(null),
// ── Chrome theme — driven by the active basemap (light-v11 → 'light',
// anything else → 'dark'). Legend/geocoder/detail-card read this and
// apply [data-theme] CSS overrides so chrome matches the map underneath.
theme: ref('dark'),
}
}
/**
* Provide the instance to descendants.
* Call this in the profile (parent) component.
*/
export function provideInstance(instance) {
provide(KEY, instance)
}
/**
* Inject the instance in a child component.
* Returns null if no instance was provided (component used standalone).
*/
export function useInstance() {
return inject(KEY, null)
}
/**
* Convenience: select a node, sync legend tab to its depth.
*/
export function selectNode(instance, node) {
if (!instance) return
if (!node) {
instance.selection.value = null
return
}
instance.selection.value = node
if (typeof node.depth === 'number') {
instance.activeTab.value = node.depth
// A REAL choice — selecting a node moves the legend to that node's generation. Mark it,
// so the profile's defaultTab stops applying from here on (see activeTabTouched above).
instance.activeTabTouched.value = true
}
}
/**
* Convenience: clear all (selection + geocoder text + pin detail).
*/
export function clearAll(instance) {
if (!instance) return
instance.selection.value = null
instance.geocoderText.value = ''
instance.pinDetail.value = null
}