Maps: Fly, share, bug fixes (#8)
* maps on pray adopt and home pages
* translation of titles
* "Fixed more info button url mapping, fixed legend gets translated fields from api using language code from parent site passed as prop, fixed legend numbers for engagement mapped to propper field"
* MERGE MAP REPOS AS PUBLIC PLUGINS
* Doxa-life is now a monorepo -> embeddables built inline -> Doxa-life /public/js serves as static CDN for serving map widgets to partner DOXA sites. UNLOCKS people of the day widget.
* Launch v1 Doxa Research Maps
* Add Go Pin Indicator 2 Research Map 2 Make Disciples
* Add Go Pin Indicator 2 Research Map 2 Make Disciples - fix map slots
* Research map: parse dialects from language field; add dialect tab + selection
- Parse "Arabic, Sudanese" (comma-inverted) and "Pakistan Sign Language" (suffix-grouped)
into base language + dialect; dedup Languages tab to one row per base language.
- Move dialect rows under base languages via expandable carat in Tab 1 (family tree)
and Tab 2 (languages). Tab 3 (Dialects) shows flat "Language, Dialect" list.
- New mapStore.selectedDialect ({ key, originalLabels }) drives dialect highlight +
exact-match pin filter. Selecting a dialect dims sibling dialects + non-matching pins;
selecting a language keeps its expanded dialect children visible (lft-row-child-active).
- _raw.primary_language is the reliable source for the comma-inverted label
(p.language gets overwritten by useMapData).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-1: Dialects/Varieties tab, parent-display title, per-map geocoder placeholder
- LegendFamilyTree: add "Dialects/Varieties" as the 3rd default tab so users
can actually reach Tab 3 from the legend bar (was only reachable via state).
- LegendFamilyTree: title row now surfaces the parent of the selected child
(e.g. "Parent: Afro-Asiatic" when Arabic is selected on the Languages tab,
"Parent: Arabic" when "Arabic, Sudanese" is selected) — solves the
"what family does Arabic belong to?" problem without leaving the tab.
- LegendFamilyTree: bump dialect indent (28→40px) and add a vertical guide
line on child rows so the hierarchical nesting is visibly clear.
- GeocoderComponent: watch the effective placeholder and patch the DOM
input directly so the per-map prop can change as the active tab switches
(Mapbox geocoder has no public placeholder updater).
- research-map: pass per-tab placeholder. On the Language Families tab the
search bar reads "Search language family, language, dialect/variety";
other tabs keep the i18n default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-2: search → tab navigation for language-family + dialect kinds
useDoxaSearch.js
- Add base-language + dialect parsing (mirrors LegendFamilyTree's SUFFIX_GROUPS).
- buildIndex now derives baseLang and dialectLabel per entry; reads the comma-
inverted label from _raw.primary_language first (p.language gets overwritten
by useMapData).
- buildAggregates: new `families` bucket (keyed by familyDerived from
langFamilyByLanguage), `languages` rebased on baseLang (so "Arabic" appears
once, not "Arabic, Sudanese" + "Arabic, Levantine"), and new `dialects`
bucket keyed by base+dialect with originalLabels accumulated.
- makeAggregateFeature emits doxa-language-family / doxa-dialect place_types
with EMOJI_FAMILY (🌳) and EMOJI_DIALECT (💬). Dialect features carry
familyDerived/baseLang/dialectLabel/originalLabels for downstream wiring.
- search() output ordering: family → language → dialect (semantic-tree top-down)
before places/religions.
GeocoderComponent.vue
- Forward doxa-language-family + doxa-dialect place_types as aggregate-result
events; thread originalLabels + familyDerived + baseLang + dialectLabel.
- Clear selectedDialect too on "All DOXA Data" click (bidirectional sync).
research-map.vue
- onGeocoderAggregateResult handles 'language-family' (selectFamily +
setActiveLegendTab('family')), 'language' (with comma-prefix + suffix-
contains pin filter so "Arabic" catches "Arabic, Sudanese" and "Sign
Language" catches "Pakistan Sign Language"), and 'dialect' (exact-match
via originalLabels + selectDialect with legend-matching key
"familyKey__baseLang__dialect" so the row indicator lights up).
- onGeocoderClear also clears selectedDialect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-3: tab info popovers (ⓘ buttons with definitions)
LegendFamilyTree.vue
- Each tab in the default tabLabels now carries an `info` field (per-tab
plain-English definition). Definitions stay generic to the tab id, so
future maps using the same component can pass their own.
- Added a small ⓘ button next to each tab label. Click toggles a popover
showing the definition; click again or click outside closes; Escape
closes. Switching tabs also closes the popover.
- Click-outside uses event.composedPath() — required for correctness across
the shadow-DOM boundary (otherwise event.target is rewritten to the host
element and the contains-check would always fail, closing on every click).
- Default copy: "A language family is a group of languages descended from
a common ancestor (e.g. Indo-European, Afro-Asiatic).", "A language is a
system of communication used by a people…", "A dialect/variety is a
regional or social form of a language…".
- Tab CSS: tab button + ⓘ now share a flex wrapper (.lft-tab-info-wrap)
with position:relative so the popover anchors to the tab cell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: revert iter-3 tab info popovers
Per-tab ⓘ buttons + popovers duplicated the existing toolbar HelpButton (?)
and added clutter the eventual mobile rewrite would have to undo. Removing:
- ⓘ button + popover template wrapper
- info field on tabLabels default
- openInfoTab ref + toggleInfo + onDocClick + onEsc handlers
- onMounted/onBeforeUnmount document listeners + composedPath() click-outside
- .lft-tab-info-wrap / .lft-tab-info-btn / .lft-tab-info-popover CSS
Tab definitions belong in the toolbar HelpButton's "About this map" modal,
not inline on the legend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: 3 bug fixes from screenshot QA
1. Sign Language duplicated in Languages tab — readFamily now consults
SUFFIX_GROUPS as a final fallback. Pins where imb_language_family is null
AND lookupFamilyFromLanguageLabel misses (e.g. "Pakistan Sign Language"
not in langFamilyByLanguage.json) used to fall through to "Unknown",
creating an Unknown__Sign Language bucket alongside the existing
Sign Language__Sign Language one — same display label, two rows. Both
highlighted on select because the selected-row check is by label, not
key. Now any pin matching " sign language$" returns "Sign Language" as
the family, collapsing both buckets into one.
2. X deselect button overlapped the population badge. Moved from right:6px
(inside the inner pill) to right:-22px (into the empty right caret column,
grid column -1). Bumped contrast (dark bg + white icon, theme-inverted
in dark mode) so it reads as a proper close affordance.
3. Tab 3 (Dialects/Varieties) flat rows had no dialect styling — looked
identical to Tab 2 language rows. Added lft-row-dialect + lft-name-dialect
classes so they get the 40px indent, italic font, and vertical guide-line
that distinguish dialects from base languages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: QA building-round-1 R2 fixes (5 items)
1. Sign-language LANGUAGES are kept distinct per QA R2 A1 — partial revert of
e26e7cd which collapsed every "X Sign Language" into one base "Sign Language"
row. SUFFIX_GROUPS renamed to FAMILY_SUFFIXES and applied ONLY in the
family-resolution path. baseLang/dialect parsing leaves the full label
intact: "Pakistan Sign Language" → family "Sign Language", base "Pakistan
Sign Language", dialect null. Each variant gets its own Languages-tab row.
Both useLanguageFamilyLegendData.js and useDoxaSearch.js updated in lockstep.
2. X deselect button now lives in its OWN grid column, not absolute-positioned
inside the inner pill. Added --lft-deselect-col (24px) and used it as the
trailing column of .lft-items grid. The X is a sibling of .lft-item (which
uses display:contents, so its inner div participates in row grid columns
2 to -2; the X lands cleanly in column -1). No overlap with badges, no
negative-margin overflow into nothing.
3. Children of selected family no longer dim (Bug 5): added lft-row-child-active
to (a) child language rows under a selected family on Tab 1, and (b) dialect
grandchildren under either a selected family OR a selected child language.
The existing CSS rule .lft-has-selection .lft-row-child-active … {opacity:1}
handles the visual exception.
4. Title-row "Field: Parent" pattern (Bug 6): replaces "Parent: X" with
level-specific labels — "Family: Afro-Asiatic" when a language is selected,
"Language: Arabic" when a dialect is selected, "Family: Sign Language"
when the family itself is selected. Default per-tab labels when nothing
is selected.
5. Cross-tab geocoder clear (Bug 11): switchTab now calls
_clearGeocoderProgrammatic() so the previous tab's search query doesn't
leak. Programmatic clear bypasses onGeocoderClear so legend selection
stays intact (only the geocoder INPUT TEXT resets).
Deferred per QA queue: Bug 7/8 count mismatches (data edge cases), Bug 9
case-insensitive search (needs repro), Bug 10 auto-collapse + scroll
(bigger UX work), file rename to SemanticTreeLegend.vue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: QA building-round-1 R3 fixes (5 items, cherry-picked from PPLR SemanticTreeLegend)
Per the user's "merge their patterns into ours, keep our rounded edges" call —
cherry-picking from /Map-Framework/05-apps/PPLR-MAPS/.../SemanticTreeLegend.vue
rather than full port. Preserves the doxa app aesthetic.
1. Bug 13 — Parent → child selection swap now works immediately.
selectFamily() and selectLanguage() now clear the OTHER legend levels
(mutually exclusive at the store layer). Watchers in research-map.vue gate
the clear-cascade: when selectedFamily transitions to null, only fire
applyDimFilter({kind:null}) if NO other level is set — otherwise the
cascade-clear from a swap would erase the new selection via clearAllHighlights.
2. X button — now visually attached to the pill with no gap. Margin-left:-6px
eats the row's column-gap so the button left-edge sits flush with the pill's
right edge. Border-radius rounds only the right corners (matches pill's right
side). Stretches to row height; the chip "sticks out" from the pill into the
reserved trailing column. No absolute positioning, no overlap with badges.
3. Cross-tab geocoder clear — wrapped behind FEATURES.geocoderPersistAcrossTabs
(default false). Flip to true to keep the geocoder query when returning to
a previously visited tab.
4. Auto-collapse + scroll-into-view on geocoder selection (Bug 10).
research-map.vue dispatches a `legend:reveal-selected` window event after
driving mapStore.selectFamily/Language/Dialect via geocoder result.
LegendFamilyTree listens, computes the ancestor-chain keys for the selected
row, and calls setExpansionToOnly(keys) — collapsing every other family.
After nextTick, scrollIntoView(behavior:smooth, block:center) on the
.lft-row-selected element. The reveal is geocoder-only — direct legend
clicks leave the user's manual expansion state alone.
5. useLanguageFamilyLegendData exposes setExpansionToOnly(keys) — generic
helper that resets the expansion map to only the listed keys. Used by the
reveal handler above; callable by any consumer that needs to drive expansion
programmatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: replace LegendFamilyTree with ported PPLR SemanticTreeLegend
Per QA building-round-1 R4 user directive — "take their legend pretty much
word for word, archive ours" — the PPLR competitor's legend ships nearly
every Round-1-3 fix in one component. Cherry-pick gave way to full port:
- src/components/SemanticTreeLegend.vue (NEW): PPLR's component ported
word-for-word (.stl-* class names + script + template + CSS). Adaptations:
* Outer .stl-panel + .stl-collapse-btn + .stl-reopen rounded-pill REMOVED.
LegendDesktop's existing collapse-to-circle (the rounded aesthetic the
user explicitly called out as missing from PPLR's pill style) wraps and
drives collapse via the #title-caret slot.
* <style scoped> → useShadowStyles, since doxa-research-mfe renders in
shadow DOM (Vue's scoped attribute selectors don't bridge the boundary).
* Light-mode selectors rebased from .stl-panel[data-theme=light] to
.semantic-tree-legend[data-theme=light] to match the new root element.
- src/composables/usePplrInstance.js (NEW): verbatim copy of PPLR's per-map
instance store. createPplrInstance/provideInstance/useInstance API.
- src/components/LegendFamilyTree.vue → LegendFamilyTree.legacy.vue:
archived in place per user direction. Not imported anywhere now.
- src/composables/useLanguageFamilyLegendData.js: new langTree computed
exports the family→language→dialect tree as { id, label, color, count,
pop, filter, originalLabels?, children }. Each node carries a pre-built
Mapbox filter expression so legend selection drives the pin filter
directly with no kind-specific dispatch downstream.
- src/components/LegendDesktop.vue: import SemanticTreeLegend instead of
LegendFamilyTree. Invoke useLanguageFamilyLegendData here to derive
langTree and pass via :nodes. Translate the new component's @select
payload back into the existing legend:highlight window event so
applyDimFilter in research-map.vue can keep using the same pipeline.
Pass detail.filterExpr through verbatim — applyDimFilter prefers the
pre-built expression over rebuilding from kind+expectedValue.
- src/components/LegendMobile.vue: same swap (SemanticTreeLegend + langTree
adapter).
- src/app-profiles/research-map.vue: createPplrInstance + provideInstance
so the legend's useInstance() inject resolves. Watch uiStore.theme and
mirror to instance.theme for light/dark chrome. applyDimFilter prefers
detail.filterExpr (pre-built node filter from langTree) over kind-based
rebuild — same end result, less duplicate logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-2: PPLR port runtime fixes (4 items)
Iter-1 (commit 2508ea3) ported the legend; build passed but four runtime
issues surfaced when traced through the actual data flow:
1. langTree family filter was unreliable. Pin features have
`properties.languageFamily` derived from `pg.languageFamily` (the API
field) which is null for all 2,069 records — so the property is mostly
"Unknown". A filter `['==', ['get', 'languageFamily'], 'Indo-European']`
would match zero pins. Fix: collect every language string under the
family (base lang + every dialect's originalLabels) and filter via
`['in', ['get', 'language'], ['literal', allLangs]]`. The `language`
property IS reliably populated. Falls back to the property match only
when the family bucket has no language strings (defensive).
2. Geocoder → legend selection was not bridged. Search picks write to
mapStore.selected{Family,Language,Dialect}, but the SemanticTreeLegend
reads from instance.selection. Added watchers in research-map.vue that
project mapStore changes onto pplrInstance.selection (using id strings
that match the langTree node ids: fam:X / lang:__X / dial:X__Y__Z).
Loop guard via the legend's existing _lastInternalId catches the
round-trip when a legend-row click → mapStore mutation → bridge fires.
3. Selection from the bridge was sparse ({id, label, depth}, no children),
so collectAllDescendantIds returned an empty Set — descendants dimmed
instead of staying full-opacity. Added findNodeById helper and made
selectedDescendants resolve the sparse selection to the full tree node
via props.nodes lookup.
4. Collapsed-state CSS targeted the old .lft-* / .lrg-* classes; the new
.stl-* SemanticTreeLegend would have shown its full inner card inside
the 36px collapsed pill, double-chrome-ing. Added equivalent rules:
.legend-container.collapsed .semantic-tree-legend .stl-tabs-wrap /
.stl-col-hdr / .stl-rows hidden; .stl-inner background/border/shadow
stripped; .stl-titlebar padding tuned to fit 36px height. Preserves
the existing rounded-circle collapse aesthetic the user emphasized.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: SemanticTreeLegend now an EXACT clone of PPLR's; mounted standalone
Per user directive: "do a direct clone, only shave the API." Iter-1's adapted
port had stripped PPLR's outer .stl-panel + .stl-collapse-btn + .stl-reopen
chrome on the assumption that LegendDesktop's circular-collapse would wrap
better. The user disagreed — PPLR's collapsed pill + right-justified UPG
columns look better. Reverting to verbatim PPLR shape.
SemanticTreeLegend.vue
- File rewritten as a true word-for-word clone of upstream (851 lines).
- Outer .stl-panel + .stl-collapse-btn (titlebar caret) + .stl-reopen (rounded
pill collapsed-state) ALL preserved. The only deviations from upstream:
* <style scoped> contents copied into a useShadowStyles() call so the CSS
lands inside doxa-research-mfe's shadow root (Vue scoped styles don't
bridge custom-element shadow boundaries cleanly here).
* useInstance() import path points to our local copy of usePplrInstance.js
(already added in iter-1; unchanged).
research-map.vue
- langTree + LANG_TABS + onSemanticTreeSelect moved up from LegendDesktop.
The legend mounts as a direct sibling of LegendDesktop in the
rm-legend-desktop-slot. Conditional v-if swaps based on activeLegendType:
language-family → SemanticTreeLegend; everything else → LegendDesktop.
- onSemanticTreeSelect uses PPLR's setFilter(node.filter) pattern verbatim.
Mirrors selection back to mapStore so the geocoder + popup + fly-to paths
keep working unchanged. Loop guarded by the legend's _lastInternalId.
- Bridge watchers now look up the FULL node in langTree (color/children/
filter all populated) instead of passing a sparse {id,label,depth}.
Searching "Arabic" in the geocoder now writes to mapStore.selectedLanguage,
the bridge finds the matching lang: node, the legend lights up the row,
expands its ancestor chain, and PPLR's setFilter(node.filter) applies.
LegendDesktop.vue
- Reverted iter-1 changes: dropped SemanticTreeLegend import + langTree
compute + onSemanticTreeSelect translator. Left a no-op v-else-if for
legendType === 'language-family' since the parent v-if hides this whole
card on that tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: stop auto-switching tabs on legend row click
Per user: clicking a child component on the Family tab was auto-switching
to the Language tab. This was a bridge bug, NOT a legend-clone deviation:
1. User clicks child language "Bengali" under Indo-European on Tab 1.
2. Legend's selectNode sets _lastInternalId = "lang:Indo-European__Bengali",
writes the clicked node to instance.selection. instance watcher fires;
guard catches (sel.id === _lastInternalId), no tab switch. ✓
3. onSemanticTreeSelect → mapStore.selectLanguage("Bengali").
4. Research-map bridge watcher fires for selectedLanguage. _findNodeInTree
searches by LABEL "Bengali" — matches the FIRST lang: node it finds.
If Bengali appears in multiple family buckets (e.g. one pin under
Indo-European, one with null imb_language_family bucketed under
"Unknown"), the find may return "lang:Unknown__Bengali" — a different
id than the one selectNode used.
5. Bridge writes that wrong-id node to instance.selection. Legend watcher
fires AGAIN; guard MISSES (sel.id="lang:Unknown__Bengali" ≠
_lastInternalId="lang:Indo-European__Bengali"). Watcher reaches the
`activeTab.value = Math.min(sel.depth, ...)` line → tab switches to
depth 1 (Language tab). Bug.
Fix: in _bridgeSelection, if the legend already holds a selection of the
same kind+label, skip the overwrite. The click-set selection (which has the
correct family-prefixed id) wins; the bridge no-ops. Geocoder picks (where
no prior selection exists, or the label genuinely differs) still update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: restore pin-click → people-group detail on language-family tab + align geocoder column
Pin-click flow was broken on the language-family tab after the PPLR-port
mounted SemanticTreeLegend standalone (LegendDesktop hidden). Tracing the
existing flow:
- Direct pin click → useMapEvents.js → uiStore.selectPeopleGroup(feature)
→ uiStore.setLegendMode('detail'). LegendDesktop watches both and renders
PeopleGroupDetail in its content slot.
- Geocoder PG pick → mapStore.selectFeature(feature). The root-level
PeopleGroupDetail (research-map line ~1370) renders.
Two parallel detail-display paths, two stores. Pin-click on the
language-family tab fell into a gap because LegendDesktop was v-if-hidden,
so uiStore.selectedPeopleGroup changes had nowhere to render.
Fix: re-mount LegendDesktop on the language-family tab specifically when
detail-mode is active. Its existing v-if priority renders PeopleGroupDetail;
SemanticTreeLegend hides for the duration. When the user closes the detail
panel (uiStore.selectPeopleGroup(null) → legendMode goes back to 'prayer'),
the condition flips and SemanticTreeLegend reappears.
Also aligned the geocoder column (.mapboxgl-ctrl-top-left) to match the
SemanticTreeLegend's .stl-panel — left:8px, width:380px — so the search bar
and the legend stack at the same x-edge as one PPLR-style column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-2: align geocoder pill geometry + focus ring to PPLR
Audited the live geocoder against PPLR's .pgc-input-wrap; three deltas:
- border-radius was 20px → bumped to 999px (true pill, matches PPLR)
- no :focus-within ring → added rgba(59,70,61,0.18) (light) /
rgba(115,161,127,0.22) (dark, PPLR's #73A17F accent at 22% opacity)
- input font-size unset → set 13px / placeholder 12px to match
(a) v-if/v-else-if branching for LegendDesktop ↔ SemanticTreeLegend
confirmed sound — no flicker beyond Vue's normal mount/unmount.
(c) Confirmed no live LegendFamilyTree imports; all hits are stale comments
+ the archived .legacy.vue file (not imported anywhere).
(d) @media(max-width:767px) mobile geocoder override still works on top of
the desktop width:380px change (uses !important).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: 3 bugs from desktop QA — pin-color, family-filter coverage, detail-card width
User report: clicking Afro-Asiatic in the legend only highlighted SOME
Arabic pins (the ones whose `language` happened to match a string in the
family bucket). Clicking Arabic itself highlighted MORE pins than Afro-
Asiatic. Also: Arabic pins on the map painted black instead of family-green
when the user expanded under Afro-Asiatic. Same root cause for both:
Pin features carried `languageFamily: 'Unknown'` because the API field
`imb_language_family` is null for all 2,069 records. The legend's family
bucketing derives client-side via langFamilyByLanguage; the PIN PROPERTY
never did. Result: the languageFamily color strategy painted Unknown =
fallback (gray/black), and any property-based family filter missed every
pin.
Fix #1: useMapLayers.js now derives `languageFamily` client-side via the
same lookup the legend uses (langFamilyByLanguage with comma-inversion +
parenthetical strip + sign-language suffix fallback). Pin colors now reflect
the legend's bucketing.
Fix #2: langTree's family filter is now belt-and-suspenders — matches by
derived `languageFamily` property OR by enumerated language strings under
the family bucket. Property path catches pins after the iter-2 derivation;
language-list path catches any pin whose `language` value matches a label
the legend bucketed even if the property derivation missed.
Fix #3: people-group detail card was wider than the SemanticTreeLegend
(LegendDesktop's --map-legend-width was 400px; .stl-panel is 380px). The
swap when the user clicks a pin now reads as same-card different-content.
applyContainerScale() bumped 400→380 at width≥700; .legend-container CSS
re-anchored to top:60px / left:8px / width:380px to match .stl-panel
exactly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map: ROOT-CAUSE fix — useMapData.deriveLanguageFamily now does comma-inversion
f72de45 fixed useMapLayers to derive languageFamily client-side, but the
fallback never ran. Tracing:
1. useMapData.loadData() runs FIRST during mfe boot.
2. Inside, it calls deriveLanguageFamily(pg) which did only an exact-match
lookup `langFamilyByLanguage[langLabel]`.
3. API returns pg.language = "Arabic, Sudanese", but
langFamilyByLanguage keys are standard form ("Sudanese Arabic"). Exact
lookup misses → returns ''.
4. canonicalFamilyName('') returns 'Unknown'.
5. pg.languageFamily = 'Unknown' for ~90% of the dataset.
6. useMapLayers' OR-chain `pg.languageFamily || pg._normalized?... ||
derive...` short-circuits at step 1 because 'Unknown' is truthy.
7. Pin features carry languageFamily='Unknown'; map paints them gray/black;
property filter matches nothing.
Real fix: upgrade useMapData.deriveLanguageFamily to mirror the legend's
lookupFamilyFromLanguageLabel logic — comma-inversion ("Arabic, Sudanese"
→ "Sudanese Arabic"), parenthetical strip ("Ainu (China)" → "Ainu"),
sign-language suffix fallback. Now pg.languageFamily = "Afro-Asiatic" for
every Arabic pin (and equivalents for other families).
Pin colors render correctly. Family filter (`['==', 'languageFamily',
familyKey]`) catches all pins. Belt-and-suspenders language-list filter from
f72de45 stays as a safety net for future format variances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map mobile iter-1: SemanticTreeLegend embedded-in-sheet overrides
User reports the mobile bottom-sheet legend doesn't render on the language-
family tab. Root cause traced: LegendMobile renders <SemanticTreeLegend>
inside .legend-content for legendType==='language-family', but the
PPLR-ported component was designed for STANDALONE desktop use — its
.stl-panel uses position:absolute with hardcoded left:8px / top:60px /
bottom:12px / width:380px, anchoring to the nearest positioned ancestor.
Inside the bottom sheet, that means the panel pins to the sheet's
boundaries with desktop-sized offsets, clipping/overflowing the small
sheet chrome. .stl-reopen + .stl-collapse-btn (the desktop chrome) ALSO
duplicate the sheet's own pull-tab + .mobile-collapse-caret.
Fix: in LegendMobile's useShadowStyles, override the embedded
SemanticTreeLegend's positioning so it flows inside the sheet content area
normally (position:static, width:100%, height:100%, transparent inner card).
Hide .stl-reopen and .stl-collapse-btn since the sheet provides its own.
Trim .stl-titlebar padding so it doesn't fight the sheet's left-padding
(which clears the absolute mobile-collapse-caret at left:6px).
Iter-2 plan: verify the mobile sheet itself appears (uiStore.isMobile +
@media(max-width:767px) breakpoint alignment); audit pin-click → detail
flow on mobile; verify the sheet's height transitions don't clip the
embedded tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map mobile iter-2 + UPGs/Pop center-align (4 items)
User QA feedback:
1. uiStore.init() never called — breakpoint stayed 'desktop' even on mobile;
resize observer never attached. Wired up in research-map.vue's onMounted
(uiStore.init?.() with optional chaining for any setup that doesn't
provide an init).
2. Mobile collapse caret looked unstyled vs the desktop's "professional gray
highlighted button" (PPLR's .stl-collapse-btn). Restyled
.mobile-collapse-caret with the same gray bg + border + rounded shape;
added .sheet-dark variant for dark theme.
3. 3-tier collapse footer (collapsed → 44px footer with no rounded corners,
only the title visible). Existing CSS used height:auto which depended on
inner content; locked to height:44px / min-height:44px so the footer is
stable. Hide SemanticTreeLegend's .stl-tabs-wrap / .stl-col-hdr / .stl-rows
when the sheet is in collapsed state, and tighten .stl-titlebar to 44px
(clears the absolute mobile-collapse-caret at left:10px via padding-left:44px).
4. UPGs/Pop columns: changed from text-align:right + justify-content:flex-end
to text-align:center + justify-content:center on both .stl-hdr-num and
.stl-num so header label and numeric values share an x-center across rows.
Pin-click → mobile detail flow (LegendMobile.vue:362 PeopleGroupDetail
branch) verified working independently of LegendDesktop's parent v-if;
unchanged in this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map mobile iter-3: tab-change reset + stronger caret styling
Sub-agent traced the language-family-tab "stuck cycle" bug:
- uiStore.legendState defaults to 'collapsed' on store creation, but is
NEVER reset on tab change.
- Switching tabs keeps the prior tab's legendState. If user left Prayer at
'fullyOpen' (the user perceives this as "70%") and switches to Language
Families, the legend opens at 'fullyOpen'.
- toggleLegend cycles fullyOpen → open → collapsed → open. So clicks go
fullyOpen → open → collapsed (footer). On other tabs this works.
- On the language-family tab, the cycle SHOULD also reach 'collapsed', but
the user reports it gets stuck flipping fullyOpen ↔ open. Most likely
cause: SemanticTreeLegend's height:100% override + internal scroll
containers may have been masking the 'collapsed' state visually, but
resetting on tab change short-circuits the issue regardless.
Fix #1 (research-map.vue:355): the activeTabId watcher now calls
uiStore.collapseLegend?.() on every tab change (skipping the very first
mount since the store already starts at 'collapsed'). All tabs — including
language-family — now start fresh at the footer tier when the user
navigates to them.
Fix #2 (LegendMobile.vue:40): bumped the .mobile-collapse-caret styling
to match PPLR's light-theme .stl-collapse-btn more closely. Background
rgba(208,215,222,0.5) (visible gray fill, not a barely-there transparent
overlay), border #afb8c1 (1px stronger contrast), color #3b463d (darker
text), 1px shadow for subtle elevation. Sheet-dark variant updated to
rgba(110,118,129,0.22) bg + #30363d border for visibility on dark sheet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-4: collapsed footer layout, search ranking, geocoder hard-clear
User QA on iter-3:
1. Caret overlapped "LEGEND" title in collapsed footer (caret left:10 + 24px
width landed at x=34, title started at x=28 from .legend-content padding).
2. Title "centered horizontally but at top of footer" — wasn't vertically
centered.
3. "LEGEND" text minute (10px font on a 44px footer).
4. Searching "arabic" lowercase didn't surface the Arabic LANGUAGE entry —
five Arabic-speaking people-groups ate the All-Data slot.
5. Search bar text persisted "Afro-Asiatic" across tab switches even though
switchTab calls _clearGeocoderProgrammatic.
Fixes:
1+2+3. LegendMobile.vue collapsed-state CSS now: padding-left:44px on
.lrg-title-row / .lft-title-row / .stl-titlebar so title clears the
absolute caret with a 10px gap; display:flex + align-items:center +
height:48px on every title-bearing element so titles vertically center;
.stl-tb-title / .lrg-title font-size bumped 10→13px; sheet height 44→48px
for slightly more breathing room. Hide-rules for data rows/columns
consolidated into one selector list.
4. useDoxaSearch.js: reorder allFlat so families → languages → dialects
come BEFORE people / places / religions. With activeFilter set, the
All-Data section now takes (families×3, languages×3, dialects×3,
people×5, places×2, religions×2) instead of slicing the flat list at 5
— guarantees representation across kinds. Total cap bumped to MAX_TOTAL+8.
5. _clearGeocoderProgrammatic now force-resets the DOM input element
directly (input.value=''), in addition to calling Mapbox's geocoder.clear().
Mapbox sometimes leaves the prior aggregate-result's text in the
<input> even after clear() returns; forcing the value=''+input event
guarantees the visible text resets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-5: mobile caret z-index, padding, title alignment, mixed-case
QA round on iter-4 (commit f42062f). All visual fixes per user feedback:
1. Caret z-index too low — caret was hidden BEHIND the legend grid on the
language-family tab. Bumped from z-index:3 → z-index:10 so the caret
floats above .stl-rows / .lrg-items.
2. Caret too large — reduced from 24×22 → 20×20 (square, smaller, sits
tighter against the sheet's top-left corner). Top:14px / left:8px
alignment matches the LegendRows title-row vertical center.
3. .legend-content left padding — reduced 28px → 8px. The wide gutter was
only there to keep clear of the absolute caret; with z-index:10 the
caret floats over the grid so the gutter is unnecessary. Title rows
handle their own 36px padding-left to clear the caret visually.
4. SemanticTreeLegend title was UPPERCASE ("LANGUAGE FAMILIES") via PPLR's
.stl-tb-title { text-transform:uppercase; letter-spacing:0.07em }. The
user's other tabs use mixed-case ("Prayer Progress", "Engagement
Progress"). Override text-transform:none + 14px system-ui font for
.legend-mobile-sheet .stl-tb-title so it reads "Language Families"
matching the other tabs' aesthetic. Dark-mode color variant added.
5. Caret overlapping the "L" of "LANGUAGE FAMILIES" in expanded state —
.stl-titlebar's left padding bumped to 36px, clearing the 8+20=28px
caret + 8px breathing room.
6. Collapsed-state titles weren't horizontally centered (LegendRows
centered fine, .stl-titlebar left-justified). Added justify-content:
center to all three title-row selectors when collapsed; symmetric
padding 36px both sides keeps the centered text from touching the caret
on the left or any future close-button on the right.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-6: fix CSS specificity collision + descender clip
User QA on iter-5: caret overlapping title text on language-family tab
("guage Families" visible — "Lan" cut off behind caret) and "Legend"
descender clipped on Prayer/Engagement/Adoption tabs.
Root cause #1 (overlap): iter-1 added a defensive .stl-titlebar rule
(padding:0 12px 4px 0!important) at line 134 to "trim padding fighting
mobile sheet". Iter-5 then added padding-left:36px!important at line 95
to clear the caret. Same specificity, line 134 wins by source order →
padding-left collapses to 0, caret covers the first 28px of title text.
Removed the iter-1 padding override entirely (its purpose is now served
by line 95 + the collapsed-state padding rule); kept the border-bottom-
color tweak.
Root cause #2 (descender clip): iter-5 set line-height:1 on collapsed
title text. With font-size:13px, the line box equals the font-em-square
exactly; descenders of 'g' / 'p' / 'y' hang below the box and get
clipped by the parent's overflow:hidden. Bumped to line-height:1.4 so
the line box accommodates descenders cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-7: per-tab legend tier memory + bright-row badge contrast
User QA on iter-6:
1. Switching tabs always collapsed the legend — user wants returning to a
tab to RESTORE whatever tier (collapsed/open/fullyOpen) it was last at.
2. White text on bright green rows (engagement-tab "Engaged", adoption-tab
"Has Adoption") was unreadable. The .lrg-name label had a text-shadow
(line 170) but the .lrg-badge-inner did not, AND the badge background
was mixing the row color with currentColor=white (lightening it on
bright rows — the wrong direction for contrast). Numbers in the pills
("26", "8.3M") nearly disappeared.
Fixes:
1. research-map.vue: replaced the iter-3 "always collapseLegend on tab
change" with a Map<tabId, legendState> that saves the outgoing tab's
legendState and restores the incoming tab's prior state (default
'collapsed' on first visit). Switching Prayer→Engagement→Prayer now
returns Prayer to its prior tier instead of always-footer.
2. LegendRows.vue:
- .lrg-badge-inner background mix changed from currentColor 22%
(lightens on bright rows in light mode) to #000 28% (darkens any
row, gives white text proper contrast).
- Added text-shadow:0 1px 2px rgba(0,0,0,0.4) on .lrg-light
.lrg-badge-inner and .lrg-dark .lrg-badge-inner — same shadow
density the label gets, so "26"/"8.3M"/etc. stay legible on
bright backgrounds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-8: bump --lrg-caret-col on mobile so caret stops overlapping title
User QA on iter-7: caret still overlapped the start of title text on
Prayer/Engagement/Adoption tabs. DevTools inspector showed .lrg-items
with no left padding.
Root cause: .legend-row-group declares --lrg-caret-col:10px (line 106 of
LegendRows.vue). The .lrg-items grid uses that as the width of column 1
(default template: var(--lrg-caret-col) minmax(80px,1fr) auto auto
var(--lrg-caret-col)). Title rows place their text in column 2.
The absolute-positioned .mobile-collapse-caret is 20px wide at left:8px,
so its right edge sits at x=28. Column 1 occupies only x=0..10 + 6px
column-gap, so column 2 (where title text starts) begins at x=16 —
DEEPLY inside the caret's footprint (caret right edge x=28 vs title text
left edge x=16 = 12px overlap).
Fix: override the --lrg-caret-col custom property to 32px ON THE MOBILE
SHEET ONLY. Column 1 now spans x=0..32 + 6px gap, column 2 starts at
x=38 — well past the caret's right edge at x=28. No overlap. Column 5
(right gutter) widens symmetrically, keeping the row visually balanced.
User's suggested approach: "increase the padding on both sides of the
prayer, engagement and adoption map. And then place the caret on the
column one to the left of the title." This is exactly that — bumping
the caret column width is equivalent to adding padding around the title
pill, while the caret continues to absolute-position cleanly within the
now-wider column 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-9: slotted mobile caret + symmetric padding cleanup
Replaces the iter-5..8 absolute-positioned .mobile-collapse-caret with a
slotted caret that lives in the title row's column-1 caret slot — matching
desktop's pattern. SemanticTreeLegend now exposes a #title-caret slot
inside .stl-titlebar (with v-if fallback to PPLR's default .stl-collapse-btn);
LegendRows already had .lrg-title-caret-slot at grid-column:1.
CSS cleanup follow-on:
- .mobile-collapse-caret: dropped position:absolute / top:14 / left:8 / z-index:10;
it's now a normal in-flow flex item that aligns with the title via the slot's
align-items:center
- .legend-content padding: 12px 0 12px 8px → 12px 0 (symmetric, no asymmetric
left gutter to clear the now-removed absolute caret)
- --lrg-caret-col on mobile: 32px → 28px (still fits 20px button + 4px each side)
- .stl-titlebar expanded: dropped padding-left:36px hack; now padding:8px 12px 8px 8px
- collapsed-state title rows: padding:0 36px 0 36px → padding:0 16px with gap:8px;
caret + "Legend" cluster centered together, no fixed offset
Outcome: caret aligns with title text via grid/flex (no magic numbers); padding
is symmetric on both sides; structure-fix replaces five iterations of CSS patches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-10: fix tier-3 fullyOpen white-space-at-bottom on mobile
When the mobile sheet was at fullyOpen tier (calc(100% - 80px)) and the
legend list was long (e.g. Language Families with 30+ entries), the area
below the visible rows showed empty white space instead of more rows
flowing into view.
Root cause: .legend-content was display:block with flex:1. Its child
shells (.stl-panel / .legend-row-group) used height:100% to fill it,
but height:100% requires the parent to have a definite height — flex:1
gives a definite COMPUTED height but no definite DECLARED height, so
height:100% falls back to auto. The shells then sized to natural content,
.stl-rows / .lrg-items lost their flex:1 height anchor (their parent was
also auto), and the inner scroll containers ended up shorter than the
sheet — leaving the gap at the bottom.
Fix:
- .legend-content now display:flex; flex-direction:column; min-height:0
- Scope flex:1; min-height:0 to .stl-panel and .legend-row-group only
(PeopleGroupDetail keeps its natural-height + outer-scroll behavior)
- .stl-panel mobile override: drop height:100%, add min-height:0
Outcome: inner scroll containers now bound to the .legend-content viewport,
last row aligns to the bottom of the sheet, no white tail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-11: add min-height:0 to .stl-rows so SemanticTreeLegend scrolls
iter-10 fixed white-space-at-bottom for Prayer/Engagement/Adoption (LegendRows
path) but missed the Language Families tab (SemanticTreeLegend path) — there
was still a white gap below the visible rows on fullyOpen mobile.
Root cause: .stl-rows has flex:1 + overflow-y:auto, but no min-height:0.
Flex items default to min-height:auto = content height, so a long list grows
past the parent instead of scrolling. .lrg-items in LegendRows already has
min-height:0 in its base CSS, which is why iter-10 worked there. .stl-rows
was missing it.
Fix: scoped mobile override .legend-mobile-sheet .stl-rows{min-height:0;}.
Outcome: SemanticTreeLegend's .stl-rows now scrolls within its parent bounds
on tier-3 fullyOpen, no white tail below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-12: clicking empty map clears stuck row-dim filter
User reported: clicking a legend row dims non-matching pins (correct), but
clicking off the row left the dim state stuck — pins stayed dimmed and
the row stayed selected with no obvious way to recover.
Fix: added a generic map.on('click') handler in onMapReady that fires
AFTER any layer-specific click. If queryRenderedFeatures finds no hits
on the interactive layers (language-family-pins, regions-fill), and any
of the prayer/engagement/adoption/family/language/dialect/region filter
states are active, clear them via uiStore setters + clearAllHighlights().
Outcome: clicking anywhere on empty map restores all pins to full opacity
and clears the row selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Simple-map iter-1: unify engagement+adoption green to neon, move prayer-page map
User feedback (2026-05-03):
1. The simple-map (used on home/prayer/adopt pages) had different greens for
engagement (#73A17F muted sage) vs adoption (#22c55e bright lime). User
wants them unified to a single bright neon green that matches the pin
color AND the legend row color in BOTH tabs.
2. The Prayer Progress map was at the bottom of the prayer page, after the
"Choose a people group" section. User wants it moved up to sit right
after the hero/prayer-goal/women-photo section so it's more visible.
Changes:
- doxa-simple-map-mfe/src/config/colorStrategies.js: ENGAGEMENT_COLORS
hasEngagement and ADOPTION_COLORS hasAdoption both → #39ff14 (matches
research-mfe engagement neon). Single source of truth — pins + legend
rows pull the same hex.
- app/pages/pray.vue: Moved the <section> wrapping <DoxaMapSlot map-id="pray-map">
from after "Choose a people group" (line 194-201) to immediately after the
hero section (line 81), placing it before "Where do I start?".
- Built doxa-simple-map-mfe with PUBLISH_TO_PUBLIC=true so public/js/
doxa-simple-map.iife.js is updated for the parent Nuxt site to serve.
NOT pushed to remote — local commit for user to test in doxa-website-nuxt
locally before any push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-13: slim mobile rows + free-drag legend pull-tab
User feedback (2026-05-03):
1. Mobile prayer/engagement/adoption legend rows look chunkier than the
thinner desktop equivalents — match desktop's compact density.
2. Pull-tab on language families / data legend tabs auto-snaps to fullyOpen
on the slightest upward drag. User wants free-drag on legend tabs (so
they can interact with the legend AND the map at the same time without
it taking over the screen). Keep auto-fullyOpen-snap behavior for
PeopleGroupDetail (legendMode === 'detail'), since they DO want a slight
pull to fill the screen with the photo + people-group content.
Changes:
- LegendMobile.vue handlePullTabTouchEnd: branch on legendMode. In 'data'
mode, leave the dragged height in place (keep _customLegendHeight set)
unless dragged below ~12% of available height (then collapse). In 'detail'
mode, retain the existing ladder-snap (open ↔ fullyOpen ↔ collapsed).
- LegendMobile.vue CSS: .lrg-item-inner min-height 36→28, vertical padding
6→2; .lrg-items row-gap 6→3. !important to win specificity over the
shared LegendRows.vue base rules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-14: fix legendMode check (was 'data', should be !== 'detail')
iter-13 introduced the mode-aware pull-tab release but checked legendMode
=== 'data', which never matches — uiStore.legendMode is 'prayer' by
default and only flips to 'detail' when a people group is selected. The
free-drag branch never ran, so the snap-to-fullyOpen path kept firing on
the legend tabs.
Fix: invert to legendMode !== 'detail'. Now legend tabs free-drag, and
PeopleGroupDetail still snaps to fullyOpen on a slight upward pull.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-15: snap legend to default 30% on pin select / detail close
User feedback (2026-05-03): the iter-14 free-drag fix let the user park the
legend at any custom height (e.g. 50%), but clicking a pin (entering detail
mode) inherited that custom height. The PeopleGroupDetail view is designed
around the canonical 30% 'open' state — the floating mini-image is
position:absolute with bottom:calc(30% + 16px), so a 50%-tall sheet hides
it, and the pull-tab from a custom-height detail behaves oddly because the
ladder-snap logic compares against the dragged height not the canonical
state height.
Fix: in uiStore.selectPeopleGroup, reset legendState to 'open' and clear
_customLegendHeight whenever a people group is selected (entering detail)
OR cleared (leaving detail). This restores the canonical 30% layout in
both transitions, matching the user's expected flow:
- User drags legend up to 50% in language-family/prayer/etc. tab
- Clicks a pin → legend SNAPS to 30%, floating image appears above
- X out of detail → legend stays at 30% (clean default state)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-16: collapsed-detail footer for PeopleGroupDetail mode
User feedback (2026-05-03): when in detail mode (people-group selected),
collapsing the sheet via pull-tab tap or drag-down didn't render a clean
footer — PeopleGroupDetail's rich content (image / actions / quick-stats /
story) tried to render at 48px height. The collapse worked, but the visual
result was a clipped/overflowing detail panel instead of the canonical
"caret + name" footer that the legend tabs get.
Fix:
- Added a dedicated .collapsed-detail-footer block in LegendMobile,
rendered v-if="legendState === 'collapsed' && legendMode === 'detail'".
Mirrors the LegendRows collapsed-state pattern: a centered caret + the
people-group name as the title. Click handler routes to handleCaretClick
to re-expand back to 30% open.
- PeopleGroupDetail v-if now also gates on legendState !== 'collapsed' so
it doesn't render at 48px.
- detail-close-btn (top-right X) is hidden when collapsed so the footer
reads cleanly; user re-expands via the caret to reach the X.
- New CSS for .collapsed-detail-footer + .collapsed-detail-title (light +
dark mode color).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-17: fix duplicate label in collapsed-detail footer
iter-16 added a collapsed-detail-footer (caret + PG name) but its v-if
gate on PeopleGroupDetail (legendState !== 'collapsed') let the fallback
v-else-if branches still evaluate — so SemanticTreeLegend (or LegendRows)
ALSO rendered with its own titlebar showing the same legendTitle (the PG
name). Result: the name appeared twice in the collapsed footer.
Fix: wrap the detail-mode branch in a <template v-if="legendMode ===
'detail' && selectedPeopleGroup"> so the v-else-if alternatives never
render when a people group is selected, regardless of state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-18: wire mobile SemanticTreeLegend @select to onSemanticTreeSelect
User reported: clicking a language-family legend row dimmed pins (correct),
but deselecting (clicking same row or X-chip) did NOT restore non-dim state.
Root cause: the mobile <LegendMobile> mounts its own <SemanticTreeLegend>
instance but did not pass an @select handler. Desktop's instance does:
<SemanticTreeLegend @select="onSemanticTreeSelect" .../>
On mobile, row clicks updated only the shared instance.selection (via
inject), never reaching mapStore.selectFamily → applyDimFilter →
clearAllHighlights. Whatever path was applying dim on mobile (likely the
shared pplrInstance affecting desktop's SemanticTreeLegend in the hidden
desktop slot) ran the dim once on selection but the deselect never
fired the symmetric clear because mapStore.selectedFamily was never set
to begin with → its watcher never ran the kind:null path.
Fix:
- research-map.vue: provide('onSemanticTreeSelect', node => onSemanticTreeSelect(node))
- LegendMobile.vue: inject('onSemanticTreeSelect') + wire @select on
the embedded <SemanticTreeLegend>
Outcome: mobile language-family row clicks now drive the canonical
mapStore.selectFamily → applyDimFilter pipeline, and deselect restores
pins to full opacity via the same kind:null clear path desktop uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Simple-map iter-2: slim rows + darken badge bg for neon green readability
User feedback (2026-05-04): two issues on the simple-map after iter-1
unified engagement+adoption to neon #39ff14:
1. The bright neon row background made the badge digits hard to read
(badge bg was lightening with currentColor 22%, washing out white text)
2. Rows felt chunky vs the slimmed research-map mobile rows
Fix (mirrors research-map iter-7 + iter-13):
- .lrg-badge-inner background: color-mix with #000 28% (darken) instead of
currentColor 22% (lighten); add text-shadow:0 1px 2px rgba(0,0,0,0.45)
on the badge inner so digits stay readable on any row color.
- .lrg-item-inner: min-height 36→28, vertical padding 6→2.
- .lrg-items row-gap: 6→3.
Built with PUBLISH_TO_PUBLIC=true so public/js/doxa-simple-map.iife.js
is updated for the parent Nuxt site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Simple-map iter-3: flat top edges on collapsed mobile footer
Mirrors research-map's collapsed-footer pattern. When the simple-map
mobile legend is collapsed (52px footer mode), the rounded top corners
flatten so the footer reads as a clean strip pinned to the bottom edge.
Rounded corners return automatically when the sheet expands, since the
base .legend-mobile-sheet rule still sets border-top-*-radius:16px and
only .collapsed zeroes them out.
Single-line CSS addition — no behavior changes elsewhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-19: swap Adoption before Engagement in tab order
User feedback (2026-05-04): tab order should follow the Prayer → Adopt
→ Engage → Language Families flow, not Prayer → Engage → Adopt.
Single-line reorder in ALL_TABS array. Per-tab logic, filters, watchers,
and Mapbox layer paint all key off `id`, not array position, so no other
changes needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-20: isolate .rm-root stacking so internal z-index doesn't leak
User reported: the geocoder pill (z-index:1200 inside the shadow root)
overlapped the page hamburger menu items.
Root cause: .rm-root had position:absolute but no z-index/transform/etc.
that would force a stacking context. Without one, child stacking values
(geocoder 1200, legend 1000) compared against page-level elements rather
than only against each other inside the widget. Any page element with
z-index < 1200 (or auto) lost the stacking battle.
Fix: add isolation:isolate to .rm-root. This is the modern, side-effect-
free way to create a stacking context — no visual change, no impact on
existing internal layering. All internal z-indexes now compare only
against each other within the .rm-root subtree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* DoxaMapSlot: inherit border-radius onto the custom element host
User reported a brief square-corners flicker on the research page —
the map slot itself had rounded corners (class="rounded-xlg") but the
custom element rendered with rectangular edges for the first few hundred
milliseconds between page paint and IIFE registration / first map paint,
making the edges appear to "snap" rounded once the map loaded.
Fix: the :deep(doxa-map) / :deep(doxa-research-map) selector now sets
border-radius:inherit + overflow:hidden, so the host element matches
the slot's curve from first paint regardless of mount timing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* DoxaMapSlot: add contain:paint for reliable canvas clipping
iter-1 (efd7800) added border-radius:inherit + overflow:hidden on the
custom element host, but the user still saw a brief square-corners flash
on the research page during initial load. Some browsers don't reliably
clip a WebGL canvas (Mapbox) to a parent's border-radius until after
the first composited repaint — overflow:hidden alone is sometimes not
enough when shadow-DOM-painted canvas content is involved.
Fix: add contain:paint to the host. This explicitly creates a paint
containment layer so the border-radius clip is enforced from the very
first frame regardless of when Mapbox's canvas paints its first tile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Research map iter-21: bump desktop tab-bar left padding to clear rounded corner
User feedback (2026-05-04): on desktop the Prayer tab sat partially
under the .rounded-xlg slot corner. Mobile padding (16px) was already
correct and shouldn't change.
Fix: split the desktop padding into start:36px / end:16px. The mobile
@media(max-width:767px) override below already resets both sides to
16px so mobile is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Ship PPLR data maps page at /research/pplr-data-maps
Adds the validation MFE bundle behind a Nuxt page that reads the
Mapbox token from runtimeConfig.public.mapboxToken (NUXT_PUBLIC_MAPBOX_TOKEN),
matching the pattern used by /research and /pray. Token is never
hardcoded — sourced from .env at runtime so secret-scanning stays clean
and tokens rotate via the host environment.
Page renders the gem-frame chrome + tab system natively in Vue, then
injects /js/pplr-data-maps.iife.js on mount. The iife defines the
<doxa-map> custom element; the dynamically created doxa-map placeholders
upgrade automatically once customElements.define runs.
Source MFE:
port-kingdom/Map-Framework/05-apps/PPLR-MAPS/PPLR-validate-data-mfe
Built with `npm run build` -> dist/pplr-data-maps.iife.js, copied here.
mapbox-gl + geocoder + d3 stay as CDN scripts (intentionally NOT bundled).
Tabs through 10 PPLR validation profiles: Religion, ROP Tree, Language
Families, Semantic Trees, PG Name Locales (primary) + Sunburst, Pins,
Rosetta Cards, Coverage (More), + Index (fixed right).
Heads up: pg-names.jsonl is 54 MB (over GitHub's 50 MB advisory but
under the 100 MB hard limit). If we want to dodge the warning later,
swap to LFS or move that one file to a CDN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add /api/maps/token endpoint, swap PPLR page back to static HTML
The Vue page conversion (previous commit) was forced by GitHub's
secret-scanning false-positive on the hardcoded Mapbox pk. token. This
commit ports the dt-geo-steward pattern (geo-steward.php :: get_temp_key)
to a Nuxt server endpoint instead, which lets us go back to a clean
static HTML page in public/.
Changes:
+ server/api/maps/token.get.ts — returns a pk.* directly OR mints a
1-hour read-only tk.* from an sk.* via Mapbox Tokens API. Caches
minted TKs in-memory until 5 min before expiry. JWT-decodes the
SK to extract the username for the API call (Mapbox 'u' field).
M nuxt.config.ts — adds private mapboxKey runtimeConfig slot
(NUXT_MAPBOX_KEY). Falls back to public.mapboxToken if unset, so
existing setups keep working with no env change.
+ public/research/pplr-data-maps.html — static page that fetches
/api/maps/token at load, sets window.PPLR_TOKEN, then injects
/js/pplr-data-maps.iife.js. Token NEVER appears in committed
files — secret-scanning safe.
D app/pages/research/pplr-data-maps.vue — replaced by the static
HTML above. Same URL semantics (/research/pplr-data-maps.html now,
no Nuxt routing involved).
Pattern can be reused by any future static MFE embed on doxa-life:
just fetch /api/maps/token, set the token, load your bundle. No
runtimeConfig dance, no page wrapper.
Heads up: requires NUXT_MAPBOX_KEY (sk. or pk.) OR the existing
NUXT_PUBLIC_MAPBOX_TOKEN to be set in Railway env. If only the public
token is set, the endpoint returns it as-is — same effective result
as before, just routed through the server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 2: prayer green unify, geocoder flag, black pin, default tab
User feedback (2026-05-04) — four changes for the simple-map and the
research-map's language-families legend:
1. Simple-map prayer green: was '#27ae60' (dark), now '#39ff14' (neon)
so all three "green" rows (prayer fullPrayer, engagement hasEngagement,
adoption hasAdoption) match exactly across all three simple-map tabs.
2. Simple-map geocoder: feature-flagged off via FEATURES.geocoder=false
in doxa-simple-map.vue. Research-map keeps its geocoder unchanged.
Flip the flag back on if/when search returns to home/pray/adopt pages.
3. Simple-map selected-pin marker: orange GO pin → simple black pin.
Removed the orange glow halo, the orange shimmer animation, the
"GO" text label, and the dark inner circle. Just a black teardrop
with white outline + a tiny drop shadow for visibility.
4. Research-map Language Families legend: default tab is now "Language"
(index 1) instead of "Language Family" (index 0). Set on the shared
pplrInstance after creation so both desktop and mobile mounts inherit
it. Users can still click the Language Family tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 3: unify greens to #22c55e, simple black pin on research, legend reflow when geocoder hidden
User feedback (2026-05-06) — three Phase-1 fixes for shipping V1:
1. Unify green across BOTH maps to '#22c55e' (the existing adoption green —
"deep but visible, not too dark, not too neon"). Replaces:
- simple-map prayerColors.fullPrayer '#39ff14' → '#22c55e'
- simple-map ENGAGEMENT_COLORS '#39ff14' → '#22c55e'
- simple-map ADOPTION_COLORS '#39ff14' → '#22c55e'
- research-mfe ENGAGEMENT_COLORS '#39ff14' → '#22c55e'
- research-mfe prayerColors.fullPrayer '#27ae60' → '#22c55e'
- research-mfe ADOPTION_COLORS '#22c55e' (already correct, no change)
Now all 6 "green" rows + pin colors match exactly across both bundles.
2. Apply the simple black/white selected-pin pattern to research-mfe (mirrors
what simple-map iter-1 received earlier). GO_ORANGE constant '#FF6600' →
'#1a1a1a' (black). createGoMarkerElement body simplified — no orange
glow halo, no shimmer animation, no inner dark circle, no "GO" text.
Just a black teardrop with a white outline + a tiny drop-shadow.
3. Simple-map: when FEATURES.geocoder=false, the LegendDesktop reflows up
from top:54px to top:10px to fill the freed slot. Driven by adding a
'dsm-no-geocoder' class to .dsm-root (bound to !FEATURES.geocoder) +
a single CSS rule that overrides the legend-container top.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 4: research caret pattern on simple-map mobile + slight padding trim
User feedback (2026-05-06) — Phase 2 of the V1 ship:
Simple-map mobile legend: now mirrors the research-mfe collapsed-footer
pattern so the caret reads more clearly and the title is centered:
- .mobile-collapse-caret bumped from a 10x10 monochrome arrow to the
research-mfe 20x20 light-gray pill (border + box-shadow + hover state).
- Inner SVG bumped 8x8 → 11x11, stroke-width 2 → 1.5.
- --lrg-caret-col bumped on mobile only (10 → 28px) so the slotted
20px pill fits with breathing room and doesn't overlap the title.
- Collapsed-state .lrg-title-row reflows to display:flex; justify-content:
center; gap:8px — caret + "Legend" cluster now sits in the middle of
the 52px footer instead of being subgrid-pinned to column 1.
Research-mfe mobile legend: slight padding trim per the "find the middle
ground" ask:
- --lrg-caret-col 28px → 24px. Pill caret is 20px so still fits with 2px
breathing room on each side; row content gets ~8px more width.
Note: not using module federation, so the caret pattern is duplicated
between the two LegendMobile.vue files. Acceptable for v1 ship.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 5: unify needs-red, rename Languages tab, dynamic selection-pin color
User feedback (2026-05-06) — three V1 ship items:
1. Needs-red across all "not" rows: notEngaged + notAdopted in BOTH bundles
change from near-black '#1a1a2e' to red '#e74c3c' so prayer's noPrayer,
engagement's notEngaged, adoption's notAdopted all share the SAME red.
Same approach as the green unification: prayer-color drives the family.
2. Tab + legend rename "Language Families" → "Languages" on research-mfe
(research-map.vue ALL_TABS label + desktop SemanticTreeLegend title +
LegendMobile fallback title). Tab id and ALL underlying logic still keys
off 'language-families' / 'language-family' so no other code touched.
3. Selected-pin color now matches the underlying dot color via a new
provide('getActivePinColor', ...) in each app-profile. useSelectedPin.js
in BOTH bundles now:
- injects the resolver
- tracks closure-state _currentColor (default GO_ORANGE fallback)
- rebuilds the marker SVG when color changes
- updates the highlight layer's circle-color paint property
Resolver in research-map.vue uses getColorStrategy(activeTab.colorStrategy)
directly; simple-map's mapping is via COLOR_MODES (engagement/adoption/
prayer) since its tab strategyKey strings don't match COLOR_MODES values.
Note: pin updates on next selection — if user switches tabs while a
group is selected, pin stays the previous color until next click. Can
add a tab-switch watcher in a follow-up if it's a real annoyance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 6: angled brush-style pin + 'advanced research map' link on simple-map pages
User feedback (2026-05-06):
1. Selected pin redesign — replaced the flat-fill teardrop with an angled
gradient-filled version that reads more like a hand-drawn / painterly
pin (per the reference image they shared). In both useSelectedPin.js
files:
- viewBox bumped to "-2 -2 32 40" so a -5° tilt around the tip (14, 35)
doesn't clip the pin's top corners.
- <linearGradient> in <defs> with three stops: lighter ${color}@92%,
${color}@100%, then #000@85% bottom-right — gives the pin volume.
- filter: drop-shadow(2px 4px 3px rgba(0,0,0,0.4)) on the SVG —
stronger offset shadow that suggests the pin is set down on the map.
- Wrapper bumped 28x36 → 32x40.
- Each marker render generates a unique gradient ID via
pinGrad-${Math.random().toString(36).slice(2,8)} so multiple pins on
the same page can never collide on the gradient ID.
- The dynamic ${color} from round 5 still drives the gradient stops,
so the pin color follows the active dot color AND has the painterly
gradient at the same time.
2. "Explore the advanced research map →" link added below the simple-map
on home (index.vue), pray (pray.vue), and adopt (adopt/index.vue) pages.
Uses NuxtLink + localePath('/research') to match the rest of the site's
routing. Right-aligned, underlined, brand color. Gives simple-map users
a clear path to the more advanced 5-tab research-map widget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 7: rephrase + enlarge 'Explore our research maps' link
User feedback (2026-05-06):
- Text: 'Explore the advanced research map' → 'Explore our research maps'.
Drops 'the advanced' (more inclusive phrasing) and pluralizes 'maps'
since the research page hosts five tabs / five distinct map experiences.
- Font size: 0.95rem → 1.2rem so the link reads as a deliberate CTA below
the simple-map and not as fine-print.
Applied to home (index.vue), pray (pray.vue), adopt (adopt/index.vue).
No MFE rebuild needed — pure Nuxt page text change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 8: untilt selected pin + theme-aware thin Joshua-Project-style data-pin outline
User feedback (2026-05-06):
1. Selected pin was visibly tilted (-5° from round 6) and looked off; user
wants a fully symmetric vertical pin. Removed the <g transform="rotate(
-5 14 35)"> wrapper around the pin paths in BOTH useSelectedPin.js
files. Painterly gradient + drop-shadow + dynamic color all preserved
— just no tilt.
2. Data-pin outline reverse-engineered from Joshua Project — they use a
very thin near-black outline that reads as a subtle definition rather
than the bright white halo we had. In light mode (default boot style
= mapbox/light-v11) the white #ffffff outline made red/orange/green
pins look "haloed" and washed out. New approach:
- Initial circle-stroke-color in addLanguageFamilyLayer:
'rgba(0,0,0,0.45)' — thin, semi-transparent near-black for light mode.
- Watcher on isDark in each app-profile flips stroke to '#ffffff' when
the user toggles to dark mode (so pins stay visible against dark
basemaps), and back to 'rgba(0,0,0,0.45)' on light.
- The existing setStyle()-then-re-add path in handleToggleTheme already
re-applies the layer; the new watcher just ensures stroke updates
reactively in both directions.
stroke-WIDTH (getCircleStrokeWidthInterpolation('standard')) already
ramps 0.5px → 1.5px from min to zoom 12, which is fine — no width
change needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 9: Joshua-Project outline now also flows through ACTIVE_LAYER overlay
User feedback (2026-05-06) — round 8 made the base data-pins thin near-black
in light mode, but selecting a legend row still drew the matched pins
through a SEPARATE 'language-family-pins-active' overlay layer whose stroke
color was hardcoded white via getStrokeColor() returning '#ffffff' (old
FEATURES.darkPinBorder=false gate). Result: filtered/highlighted pins kept
the old bright white halo while non-matched dimmed pins had the new thin
outline — visually inconsistent.
Fix in simple-map app-profile:
- getStrokeColor() drops the FEATURES.darkPinBorder gate; now returns
isDark.value ? '#ffffff' : 'rgba(0,0,0,0.45)' to match round 8's
base-layer watcher.
- getFilterStrokeColor() simplified — the DARK_PIN_KEYS exception
(returned white for 'notEngaged'/'notAdopted') no longer matches its
original intent because round 5 changed those pins from near-black to
red (#e74c3c). All filter cases now use getStrokeColor() uniformly.
Research-mfe was already fine — it dims via circle-(stroke-)opacity on the
same language-family-pins layer rather than drawing a separate active
overlay, so the round-8 stroke-color change covers it without extra work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Maps round 10: feature-flag the active-overlay emphasis off on simple-map
User feedback (2026-05-06): selecting a legend row added too much visual
noise — the matched pins got a thicker outline, an enlarged radius, AND
a pulsing animation via a separate ACTIVE_LAYER + PULSE_LAYER overlay.
Want just "dim the others" and call it done.
Added FEATURES.activeOverlayEmphasis (default false). When false:
- initActiveLayer() short-circuits at the top — neither ACTIVE_LAYER nor
PULSE_LAYER ever get added, so no rAF loop, no animation, no extra stroke.
- applyLegendFilter dim path: instead of recoloring base to mutedGray and
drawing matches via the (now-absent) overlay, the base layer's
circle-opacity + circle-stroke-opacity become 'case' expressions on
matchExpr — matched pins stay at 0.9 / 1.0, non-matched dim to 0.15 /
0.15. Color stays at the tab's normal palette.
- clear-filter branch restores circle-stroke-opacity:1 (it was previously
set to 0 via the muted-gray dim path).
- Legacy overlay code is kept behind the flag in case someone wants to
flip it back on.
Visual outcome: matched pins look IDENTICAL to their unfiltered state
(thin near-black outline from round 8/9, no halo); only non-matched pins
fade. No animation. Matches what the user asked for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Replace doxa-{simple,research}-mfe with unified 1040-maps forge
Two cloned MFEs (doxa-simple-map-mfe, doxa-research-mfe) collapsed into
a single Vite monorepo at embeddables/micro-frontends/1040-maps/, ending
the clone-and-drift pattern that was shipping divergent SemanticTreeLegend,
colorStrategies, useMapData, etc. across maps.
Architecture:
src/ shared library (components, composables, config,
stores, i18n, utils, ProfileLoader)
app-profiles/<n>/ per-bundle source folders
├─ index.html (staging)
├─ index.js (custom-element entry)
└─ profiles/ (bundle-private .vue profiles)
npm run build loops app-profiles/* → emits one IIFE per bundle
directly to ../../public/js/<bundle>.js
Per-bundle build (BUNDLE=<name> vite build) lets each bundle ship as a
single self-contained IIFE with CSS inlined via vite-plugin-css-injected-
by-js. Tree-shaking + workspace-style sharing replaces Module Federation;
nothing ships across bundle boundaries that the bundle's profiles don't
import.
Bundles produced:
public/js/doxa-simple-map.js 377 KB (3 simple-map profiles)
public/js/doxa-research-map.js 1.25 MB (1 research workbench profile,
7 outer tabs incl. new Religion)
New research-map outer tabs (vs original): Affinity Blocks (4-tier:
Bloc → Cluster → PG → PGIC), Religions (10 family rows, Christianity
filtered out via excludeReligionFamilies opt). Vertical separator
between tier-1 (Prayer/Adoption/Engagement = simple-map regurgs) and
tier-2 (Languages/Affinity Blocks/Religions = research-only).
Custom-element ownership: <doxa-map> exclusive to simple-map bundle;
<doxa-research-map> exclusive to research bundle. Disjoint to prevent
the SPA-navigation tag-collision bug where /research → /home would
have <doxa-map> stuck on the research bundle's profileModules.
Site wiring: useDoxaMap.ts paths updated to .js (no .iife suffix).
DoxaMapSlot already routes to distinct tags per bundle.
Old MFE folders (doxa-simple-map-mfe/, doxa-research-mfe/) removed —
historical archive lives at Map-Framework/1040-maps/ if needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: rename legend title from Affinity Blocks to People Groups
* feat(1040-maps): modern map styling — Apple Maps / Felt quality polish
Why: The map's GIS-tool-era styling (black borders, high-saturation fills,
dark pin outlines) undermined the polished UX the research tool needs.
Modern map products (Apple Maps, Felt.com, Mapbox Standard) use low-opacity
tinted fills, sub-pixel borders, white pin strokes, and drop shadows.
Changes:
- Polygon fill: opacity 0.35 → 0.20, add fill-antialias + 300ms transition
- Borders: #000000 width 1 → rgba(60,60,80,0.35) width 0.6
- Pin stroke: rgba(0,0,0,0.45) → rgba(255,255,255,0.9) white for contrast
- Pin shadow: new language-family-pins-shadow layer (circle-blur + translate)
- Selection dimming: selected 0.30 / unselected 0.06 (stronger spotlight)
- Unselected border: rgba(60,60,80,0.15) width 0.3 during selection
- Region insert z-order: prefer shadow layer as beforeId anchor
- All reset paths (clearAllHighlights, onSemanticTreeSelect, applyDimFilter)
updated to new baseline values
Verified: All 7 region hex colors preserved. Hitbox layer untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(legend-mobile): add doxa-regions (WAGF Regions) to mobile SemanticTreeLegend
Why: LegendMobile only gated SemanticTreeLegend for language-family and
affinity-block tabs — the doxa-regions tab fell through to LegendRows
(flat list), which can't render the 3-tier Region/Block/Country tree.
This also meant the WAGF Regions tab on mobile showed the wrong component
with mismatched data, appearing as empty/no content.
Fix:
- Import useWagfRegionsLegendData and wire regionsTreeForMobile
- Add REGIONS_TABS_MOBILE constant (Region/Block/Country)
- Update treeForMobile, tabsForMobile, treeTitleForMobile computeds
to handle 'doxa-regions' as a third branch
- Add 'doxa-regions' to the template v-else-if condition so the
SemanticTreeLegend renders instead of LegendRows on mobile
The desktop path (research-map.vue) already handles all three legend
types via legendNodes/legendInnerTabs/legendTitle computeds and the
onSemanticTreeSelect handler already processes region:/block:/country:
node id prefixes — no changes needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(research-map): add mobile edge scroll padding instead of cooperativeGestures
Why: on mobile, cooperativeGestures forced a two-finger-zoom requirement that
trapped users — they couldn't scroll past the map without using two fingers.
The ctrl-to-zoom overlay was also undesirable on desktop.
Fix: remove cooperativeGestures entirely (desktop gets direct scroll-zoom with
no ctrl requirement). Add two invisible 40px-wide touch strips along the left
and right edges of the map that have touch-action:pan-y, so mobile users can
grab either edge to scroll the page past the map without getting stuck.
The strips are pointer-events:none on desktop (no interference) and
pointer-events:auto only on mobile via @media(max-width:768px).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(legend): match flat legend carets + collapsed pill to SemanticTreeLegend
Why: Prayer/Adoption/Engagement/Religion tab legends used a bare 10x10px
downward-chevron caret and a plain border-radius:20px collapsed state.
SemanticTreeLegend (Languages/PG/Regions tabs) has a polished 22x20px
button caret with hover glow and a beautiful floating pill when collapsed.
The visual inconsistency made the flat legends look unfinished.
Changes:
- LegendDesktop: replace .legend-collapse-caret with STL's .stl-collapse-btn
styling (22x20px, rounded-rect, dark bg + border, green hover accent).
SVG swapped from downward chevron to STL's right-chevron with 180deg
rotation. Collapsed state now uses STL's .stl-reopen pill appearance
(border-radius:999px, dark bg, floating shadow, hover lift).
- LegendRows: widen --lrg-caret-col 10px -> 30px to accommodate the
larger caret button with breathing room on both sides.
- LegendMobile: unify .mobile-collapse-caret to 22x20px STL dimensions,
match bg/border/color/hover to STL's light and dark palettes, SVG
updated to 9x9 with 1.8 stroke-width.
- Full light/dark mode support on all three components.
Verified: npm run build succeeds (both doxa-research-map + doxa-simple-map).
No backticks in useShadowStyles strings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(legend): collapsed pill parity, pin dimming, tab scrollbar fix, legend state preservation
- LegendDesktop collapsed state now uses separate reopen pill button
matching SemanticTreeLegend's stl-reopen exactly (dark/light modes)
- Unselected pins fully disappear (opacity 0) when legend row selected,
including shadow layer
- Pin strokes: thin dark rings on light mode, white on regions/dark mode
- Legend state preserved when switching tabs (no forced collapse)
- Tab bar horizontal scrollbar hidden on desktop
- Religion "Other" color changed to vivid orange for visibility
- Collapse-deselect: collapsing parent deselects selected children
- Tab renamed "WAGF Regions" → "Regions" (legend title kept as WAGF Regions)
- Legend state preserved when clicking pin then closing detail panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(1040-maps): prayer ripple animation, unified legend labels, mobile legend parity
Prayer ripple: CSS-keyframe-inspired expanding rings on prayed-for pins.
4 rings staggered at 1.5s intervals over a 6s cycle, scaling from 0.3x
(inside pin) to 2.5x with linear opacity fade. Color-coded by prayer
status (orange = has prayer, green = fully prayed). Glow coupled to
legend filters via syncGlowFilter. Unprayed pins render on top (z-index 3)
so they draw attention.
Legend: title always "Legend", column header shows tab-specific label
(Prayer Status, World Religions, etc.) in larger monospace text. Both
simple and research maps unified. Simple map now uses SemanticTreeLegend
on desktop + LegendMobile on mobile (was STL-only). Desktop defaults open,
mobile defaults collapsed. Tab switch preserves legend state.
DoxaMapSlot: removed bottom scroll pad that blocked mobile legend touches.
Added left/right scroll edges inside shadow DOM for simple map parity.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(DoxaMapSlot): only render top scroll pad for simple maps
The top scroll pad (z-index 1500, pointer-events:auto, 48px) was blocking
touch/click events on the research map's tab bar on mobile. Research maps
have their own tab bar occupying the top area and don't need the scroll pad.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(legend): cap panel height to viewport instead of stretching to bottom on tablet
Why: On tablet (desktop layout), the SemanticTreeLegend .stl-panel used
`bottom: 12px` absolute positioning, stretching the panel from top:60px
to the viewport bottom regardless of content height. This created visible
white space below the last row on region and people-group tabs where
content is shorter than the available space. The prayer/adoption/engagement
tabs already avoided this via the `.fit-content` class which set
`bottom: auto; max-height: calc(100vh - 80px)`, but tree tabs (regions,
people-groups, language families) did not have `hideTabs` set, so the
class was never applied.
Fix: Remove `bottom: 12px` from the base `.stl-panel` rule and add
`max-height: calc(100vh - 80px)` to all panels. The flexbox column
layout naturally shrinks to content height, and the max-height cap
prevents overflow past the viewport. The `.stl-rows` container already
has `overflow-y: auto`, so scrolling activates automatically when
content exceeds the capped height. Also updated the @media(max-width:640px)
override to use max-height instead of bottom.
Verified: both bundles build cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(legend): hide SemanticTreeLegend when people-group detail opens
Why: When a pin was clicked on tablet, the SemanticTreeLegend stayed
visible behind the PeopleGroupDetail panel. The v-show directive was
applied directly to the SemanticTreeLegend component, which has two
root elements (.stl-panel div and .stl-reopen button). Vue 3's v-show
on a multi-root component fails silently — it cannot set display:none
on a fragment, so the component remained visible.
Fix: Wrap SemanticTreeLegend in a single-root <div> and move v-show
to the wrapper. The wrapper is a plain block element with no positioning
of its own — .stl-panel inside handles its own absolute positioning.
When detail mode activates, the wrapper's display:none properly hides
both the panel and the reopen pill.
Verified: both bundles build cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pins): increase touch target to 44px+ on tablet zoom levels 4-8
Why: Pin tapping on tablet required too much precision. The invisible
hitbox layer used the 'large' circle radius profile which only reached
6px radius (12px diameter) at zoom 4 and 14px (28px) at zoom 8 — well
below the 44px minimum recommended by Apple HIG and 48px by Material
Design for comfortable touch targets.
Fix: Add a 'touchTarget' interpolation profile to zoom.js with 22px+
radius at zoom 4-8 (44-52px effective diameter). The hitbox layer in
useMapLayers.js now uses this profile instead of 'large'. The visual
pins remain at their standard size — only the transparent hit-test
area grows. At zoom 0-2 the hitbox is 14-18px radius (still larger
than visual pins), scaling up to 40px radius at zoom 14.
Touch target diameters at key zoom levels:
zoom 4: 44px (meets Apple HIG)
zoom 6: 48px (meets Material)
zoom 8: 52px (comfortable)
Verified: both bundles build cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(region-popup): wire WAGF Region, WAGF Block, and UUPG count into country-click detail
Why: useMapLayers created its own internal useMapEvents instance without
passing getNormalizedPeopleGroups, so the region popup always received an
empty array — UUPG count was 0 and WAGF fields showed dashes. Additionally,
DataSourceManager only created *Label fields for {value,label} objects,
not plain strings, so APIs returning string region names produced no label
field. The doxa-api source also lacked a countryIso field mapping, causing
the ISO-3 match to fail for that data source.
Fix:
- useMapLayers now accepts and forwards getNormalizedPeopleGroups to its
internal useMapEvents, so the region popup gets the full people-group array
- DataSourceManager objectFields loop now creates *Label fields for plain
strings too (if no label already exists), making the popup resilient to
both {value,label} objects and raw string values from any API
- doxa-api sources.json gains countryIso → imb_isoalpha3 mapping parity
with pray-tools (country_code) and doxa-csv (ISOalpha3)
- generateRegionPopupContent falls back through countryIso → country for
ISO matching, and wagfRegionLabel → doxaRegionLabel → doxaRegion → wagfRegion
for display
Verified: both doxa-research-map and doxa-simple-map bundles build cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(legend): constrain desktop panel height to map container, not viewport
Why: commit db805e6 capped .stl-panel with calc(100vh - 80px), but 100vh
references the browser viewport. When the map is embedded in a page with
a header/nav above it, the legend extends past the map container bottom.
Fix: switch to calc(100% - 72px) so the max-height is relative to the
nearest positioned ancestor (.rm-map-area, position:relative). The 72px
accounts for the top:60px offset plus a 12px bottom gap. Short-content
tabs still shrink-to-fit (max-height, not height). .stl-rows already has
overflow-y:auto so a scrollbar appears when content overflows.
Mobile breakpoint (@media max-width:640px) left untouched — it correctly
uses calc(100vh - 72px) since the map fills the viewport on mobile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(research-map): add standalone embed page for partner websites
Why: partners need a simple URL they can iframe or link to directly to
show the research map on their own sites. The existing pplr-data-maps.html
is a complex multi-tab showcase unsuitable for embedding.
What: minimal full-viewport HTML page at /research/doxa-research-map.html
that fetches the Mapbox token from /api/maps/token, loads the
doxa-research-map MFE bundle, and renders a single <doxa-research-map>
custom element filling 100vw x 100vh on a dark background. No tabs, no
gem frames, no showcase UI — just the map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(research-map): add missing dataSource and instanceId to embed profile-config
Why: the standalone embed page only passed profile + tk to <doxa-research-map>,
but DataSourceManager requires dataSource: 'pray-tools' to resolve the API
endpoint. Without it the fetch never fires and the map loads empty. Also adds
instanceId: 'embed-research' for unique map container IDs matching the pattern
used by the Nuxt research page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(research-map): add Share button with iframe embed snippet for partners
Why: partners need a frictionless way to embed the research map on their
own websites. Currently there is no share/embed affordance in the toolbar
so partners must manually construct an iframe tag or request the URL.
What:
- New ShareButton.vue following the MapControlButton wrapper pattern
- Popover with pre-filled <iframe> snippet and direct-link sections
- One-click copy-to-clipboard with "Copied!" visual feedback (2s revert)
- Embed URL computed from window.location.origin (works on staging + prod)
- Click-outside and Escape close the popover
- Dark/light theme support via useShadowStyles, mobile-responsive popover
- Added to research-map toolbar only (not simple-map)
- i18n "shareEmbed" key added to all 11 locales
- Both bundles verified: npm run build passes cleanly
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(research): add share-multiplier animated SVG for partner communication
Why: project managers need a visual that immediately conveys the value
proposition of the share/embed button — one research map deployed on
doxa.life can multiply into 5+ prayer surfaces across partner websites
via iframe embedding.
Animation sequence (10s loop):
1. Center browser shows the doxa.life research map with 10/40 window dots
2. Share icon pulses + <embed/> snippet emerges
3. Green flow lines connect to 4 partner browser windows that appear sequentially
4. All 5 maps pulse with green ripple — "1 → 5x prayer surfaces" label fades in
Style: dark theme (#0b0f14), green prayer accent (#22c55e), blue share accent
(#60a5fa). Pure CSS animations, no JS, standalone SVG, under 20KB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(research): replace share-multiplier SVG with two-panel light-mode design
Why: the dark-mode (#0b0f14) SVG clashed with the light website context.
The new design communicates the share-to-embed story more clearly with
a two-panel layout: a partner-facing hero (65%) showing the doxa.life
browser -> share button -> 3 partner sites animation, and a developer
pipeline strip (35%) showing the Vue -> Vite -> bundle -> iframe flow.
Visual: white background, cream/beige landmass map, blue share button
(#2563eb), green people-group dots (#22c55e), animated blue flow lines,
sequential partner window activation with green glow, 10s CSS loop.
24.8KB, pure SVG + CSS keyframes, no JavaScript.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(research): two-panel dark-theme share-multiplier SVG
Why: the share-multiplier visual needs to serve two audiences in one
graphic -- project managers who need the "one click = 5x prayer" story,
and developers who need the build pipeline schematic.
Top panel (~70% height): animated hero "MULTIPLIED"
- Center doxa.life browser with 10/40 window map + green people-group dots
- Blue share icon pulses, <embed/> snippet emerges
- 5 partner browser windows (partner-a.org, church-b.com, missions-c.net,
pray-d.org, network-e.org) appear sequentially via green flow lines
- Green ripple bursts expand from all 6 maps simultaneously
- "FOR DOXA PARTNERS" section label
Divider: gradient horizontal line + "HOW IT WORKS" label
Bottom panel (~30% height): static architecture pipeline
- 7-stage left-to-right: Vue Source -> Vite Build -> .js Bundle ->
HTML Embed Page -> /public Nuxt Host -> iframe Snippet -> Partners
- Color-coded: green=Vue, blue=Vite+iframe, amber=bundle, green=partners
- Monospace annotations map each stage to actual filenames
- "FOR DEVELOPERS" section label
Style: dark theme (#0b0f14), green prayer (#22c55e), blue share (#60a5fa).
Pure CSS @keyframes, no JS, 10s seamless loop, 23KB standalone SVG.
Replaces light-mode version from c491f99 with spec-matching dark theme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(share): redesign ShareButton for non-technical users
Why: the original share popover led with a raw <iframe> tag and a
"Direct Link" section — fine for developers but intimidating for
ministry partners and church websites. Market research of YouTube,
Spotify, Canva, and Google Maps showed that consumer-grade share
modals always lead with "Copy Link" and hide embed behind a
friendly tab.
Changes:
- Two-tab design: "Share Link" (default) and "Add to Your Website"
- Share Link tab: clean URL display, large Copy Link CTA, email +
WhatsApp social icons
- Embed tab: plain-English description (never mentions "iframe"),
Small/Medium/Large size-preset pills that live-update the code,
styled code box with Copy Code button
- Both copy buttons show checkmark + "Copied!" feedback for 2s
- Box-with-arrow-up share icon (iOS/Material standard)
- Header with close X, smooth pop-in animation
- Full dark/light theme support via isDark prop
- Mobile-responsive: wider popover positioned below button
- All styles via useShadowStyles (shadow DOM compatible)
- Build verified: both research-map and simple-map bundles compile
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(share): portal z-index fix, touch targets, and topojson deps
ShareButton popover now portals to shadow-root level so it paints above
the mobile legend (z-1000). Touch targets bumped to 44-48px min-height.
Added topojson-client and world-atlas deps. Rebuilt research-map bundle.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(share+legend): mobile share popover positioning and collapsed legend caret
ShareButton mobile: pin popover at top:120px (clears the search bar pill),
left:10/right:52 to align with MapToolbar and leave room for the toolbar
buttons. Slim header to match SemanticTreeLegend's .stl-titlebar styling
(32px min-height, mono uppercase title). Removed Share via email/WhatsApp
row. Click-outside now only closes when click hits the bare map canvas so
interacting with the legend or other UI keeps the popover open.
LegendMobile: add max-height:none override on .stl-panel so the recent
calc(100% - 72px) cap (commit 81816a0) doesn't zero-out the panel inside
the 48px collapsed sheet, which was hiding the caret + title.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(legend): rebuild doxa-simple-map bundle to ship collapsed-caret fix
The .legend-mobile-sheet .stl-panel max-height:none override in
LegendMobile.vue (commit e4bbc79) was only rebuilt into doxa-research-map.js
at the time. doxa-simple-map.js (pray/adopt/home pages) still shipped the
pre-fix CSS, leaving the mobile collapsed legend pill blank and untappable
on the simple-map host pages.
Rebuild only the simple-map bundle: BUNDLE=doxa-simple-map vite build.
Source LegendMobile.vue is unchanged — already correct. Diff is the new
max-height:none!important rule + Vue's auto-rotated scopeId hash.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(map-fly): add uniform zoomToLegendRow camera meta-solution
Card d1336834 — STL legend row click → camera fit, derived from the live
language-families pin source (NOT hardcoded per-region coords). Uniform path
for polygon / pin-cluster / single-pin rows: 1-pin flyTo @ zoom 8,
≥2-pin fitBounds with 40px padding + 800ms duration.
Caller passes the row + optional matchExpr override (for flat tabs whose
row.filter is a _flat_filter placeholder). zero-match rows = no-op so the
map never blanks out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(map-fly): wire legend rows → camera in both map app-profiles
Card d1336834 — research-map calls mapFly.zoomToLegendRow(node, …) at the
top of onSemanticTreeSelect so every legend-row click (tree tabs OR flat
prayer/engagement/adoption/religion tabs) moves the camera to fit the row's
scope. Flat-tab nodes get a synthesized matchExpr via _buildFlatLegendMatchExpr
(node.filter is a _flat_filter placeholder that never matches real pins).
simple-map gets useMapFly instantiated for the first time and routes its
prayer/engagement/adoption row clicks through buildMatchExpr → zoomToLegendRow.
Same uniform gesture as research-map: Africa-row fits Africa, North-India-
cluster fits the cluster, single-pin → flyTo zoom 8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build(map-fly): rebuild simple-map + research-map bundles for legend-row camera
Card d1336834 — bundles include useMapFly.zoomToLegendRow + the per-profile
onSemanticTreeSelect wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(map-fly): evaluate legend filter against source data, not viewport
map.querySourceFeatures(sourceId, {filter}) is viewport-clipped — when
the camera was zoomed in on Africa and the driver clicked South Cone (or
any other off-screen region row), Mapbox returned zero matches and the
function silently no-op'd. fitBounds is bidirectional, but it never got
a chance to fire.
zoomToLegendRow now reads map.getSource(sourceId)._data and evaluates the
Mapbox filter expression in JS against every feature's properties. The
mini evaluator handles the expression shapes the legend producers actually
emit (==, !=, >, >=, <, <=, all, any, in, match, has, get, literal,
coalesce, to-number, plus legacy ['==', 'prop', value] form).
After the fix:
- Zoomed in on Africa + click South Cone → camera zooms OUT and pans
to South America South Cone bounds.
- Any region/religion row while zoomed in → fits that scope.
- Both research-map and simple-map share the same path.
Includes rebuilt public/js/doxa-research-map.js + doxa-simple-map.js.
Card: d1336834-3d6b-410c-9f6d-d11ebbd0bdaf
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(map-fly): probe fit zoom vs minZoom to prevent mobile snap on wide scopes
Why: on mobile, the legend row scope (prayer/adoption/engagement) often spans
a region wider than the mobile viewport can render at its effective minZoom.
fitBounds animates toward a zoom < minZoom and Mapbox clamps mid-animation,
producing a visible snap/jolt.
Fix: in zoomToLegendRow >=2-pin path, call map.cameraForBounds(bounds, {padding})
WITHOUT animating, compare the would-be zoom against map.getMinZoom() with
0.3 slack. When bounds would clamp, flyTo({center, zoom: minZoom}) instead
of fitBounds — same duration, no snap.
Card: 0912e936-96da-4a62-b3d7-7f9c52b25163
* fix(maps): mobile bug wave — viewport, fullscreen, legend, filter, animation
Mobile (iOS Safari):
- viewport meta added with pinch-zoom allowed + viewport-fit=cover
- geocoder input forced to 16px to prevent iOS auto-zoom on focus
- pseudo-fullscreen for iOS (real requestFullscreen rejected on non-video)
- locks html/body overflow + body.position:fixed + scrollY save/restore
- touch-action:none on root + capture-phase touchmove/wheel blockers
so pad-zones around the host can't scroll the page behind
- simple-map MapToolbar moves to top:10 when has-geocoder=false so the
no-geocoder profiles (home/pray/adopt) don't leave a dead gap
Legend:
- detail-mode collapse caret is now an invisible 28x28 tap zone (no
visible chevron overlapping the people-group name)
- click handler always toggles; previous early-return in detail mode
removed; watcher no longer auto-re-expands during user collapse
- empty-map click no longer clears legend filter — selection is sticky;
only same-row re-tap or close button clears
Filter:
- hidden pins are removed from the language-family-pins-hitbox layer
(setFilter), so dimmed/invisible pins are no longer hit-testable
- pplrInstance.selection cleared alongside mapStore on intentional
clearAllHighlights paths so legend state mirrors map state
Zoom (useMapFly):
- religion legend rows zoom correctly (added slice handler to filter
expression evaluator)
- doxa-regions rows fit the polygon source instead of pin clusters
(Oceania-across-antimeridian no longer zooms out to span both copies)
- antimeridian-aware longitude bounds prevent global-span zoom-outs
- LEGEND_FIT_MAX_ZOOM=5 cap so single-pin clicks don't slam to street
Prayer-glow animation:
- removed persistent _glowStyleHandler — host map.once('style.load') is
the sole reconciliation path on theme swap
- addPrayerGlowLayers truly idempotent: returns early if all 5 layers
already exist, no remove+re-add churn that caused the 2-second flash
- _glowEpoch token + cancel-before-schedule pattern preserved
Labels:
- "World Religions" → "UUPG Religions" on religion tab header
- "UPGs" → "UUPGs" column header in semantic tree (across 11 locales)
Misc:
- fixed duplicate const config in app/layouts/default.vue (auto-merge
artifact from earlier merge of master)
* remove duplicate feedback widget
---------
Co-authored-by: swift-river542 <swift-river542@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df4c480be8
commit
0fe471dc8c
62 changed files with 1215 additions and 1015 deletions
|
|
@ -6,7 +6,7 @@
|
|||
// (PHP equivalent: `get_query_var('uupg_search')`).
|
||||
//
|
||||
// Below the UupgsList we embed the 5-tab `research-map` IIFE built from
|
||||
// Map-Framework/apps/DOXA-MAPS/doxa-research-mfe — exposes Engagement / Prayer
|
||||
// the upstream DOXA-MAPS module — exposes Engagement / Prayer
|
||||
// / Adoption (clones of doxa-simple-map) plus Language Families. The
|
||||
// 1040-maps build now publishes /js/doxa-research-map.js (canonical name).
|
||||
// via the publishToDoxaLife() plugin.
|
||||
|
|
@ -50,7 +50,7 @@ useTextHighlight()
|
|||
|
||||
<!-- Research map embed — sits directly under the H1, above the UUPG
|
||||
search bar. Loads /js/doxa-research-map.js (auto-published from
|
||||
Map-Framework on every research-mfe build). -->
|
||||
the upstream module on every research-mfe build). -->
|
||||
<DoxaMapSlot
|
||||
map-id="research-page-map"
|
||||
bundle="research-map"
|
||||
|
|
|
|||
10
embeddables/micro-frontends/1040-maps/.gitignore
vendored
10
embeddables/micro-frontends/1040-maps/.gitignore
vendored
|
|
@ -23,3 +23,13 @@ dist/
|
|||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Internal engineering scratch — never commit
|
||||
.local-scratch/
|
||||
.lk10x/
|
||||
.agent-restricted
|
||||
build-status.md
|
||||
embeddables/**/intel/
|
||||
|
||||
# Sub-project lockfile — root bun.lock is authoritative
|
||||
bun.lock
|
||||
|
|
|
|||
|
|
@ -1,40 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: 1040-maps
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
size-class: short
|
||||
svg-candidate: true
|
||||
svg-candidate-reasons:
|
||||
- ascii-diagram:20
|
||||
svg-candidate-score: 3
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.449172+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:37.391885+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- mapbox-mfe-bundles
|
||||
- repo-map-helps
|
||||
- template-src-drifting
|
||||
- library-components-composables
|
||||
- semantictreelegend-colorstrategies-happen
|
||||
- exists-replaces-clone
|
||||
- vite-powered-monorepo
|
||||
voice-memo-shape: false
|
||||
word-count: 298
|
||||
---
|
||||
|
||||
# 1040-maps
|
||||
|
||||
A Vite-powered monorepo + map forge for building embeddable Mapbox MFE bundles. Named after the 10/40 window — the geographic frame most of these maps serve.
|
||||
|
|
@ -91,4 +54,4 @@ npm run build
|
|||
|
||||
## Migration plan
|
||||
|
||||
See `Map-Framework/00-input/map-framework-refactor/migration-001-doxa-simple-map.md` for the first refactor (doxa-simple-map-mfe → 1040-maps).
|
||||
See `the refactor ideation` for the first refactor (doxa-simple-map-mfe → 1040-maps).
|
||||
|
|
@ -1,41 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/app-profiles/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: app-profiles
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
- app-profiles
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons:
|
||||
- ascii-diagram-light:15
|
||||
svg-candidate-score: 1
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.449861+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:37.759492+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- map-vue-profile
|
||||
- app-profiles-subfolder
|
||||
- iife-js-folders
|
||||
- config-nested
|
||||
- staging-folder-index
|
||||
- map-renders-doxa
|
||||
- vite-multi-entry
|
||||
voice-memo-shape: false
|
||||
word-count: 161
|
||||
---
|
||||
|
||||
# app-profiles/
|
||||
|
||||
Each subfolder here is a bundle. Folder name = bundle name. The Vite multi-entry build discovers folders here at build time and emits one IIFE per folder into the sibling `app/` directory.
|
||||
|
|
@ -70,5 +32,5 @@ A bundle can mix all three.
|
|||
|
||||
## See also
|
||||
|
||||
- Architecture: `Map-Framework/00-input/map-framework-refactor/ideation3.md`
|
||||
- First migration: `Map-Framework/00-input/map-framework-refactor/migration-001-doxa-simple-map.md`
|
||||
- Architecture: `the refactor ideation`
|
||||
- First migration: `the refactor ideation`
|
||||
|
|
@ -669,7 +669,7 @@ const legendInnerTabs = computed(() => {
|
|||
})
|
||||
const legendHideTabs = computed(() => _isFlatTab.value)
|
||||
const legendColumnLabel = computed(() => {
|
||||
if (activeLegendType.value === 'religion') return 'World Religions'
|
||||
if (activeLegendType.value === 'religion') return 'UUPG Religions'
|
||||
if (activeLegendType.value === 'prayer') return 'Prayer Status'
|
||||
if (activeLegendType.value === 'engagement') return 'Engagement Status'
|
||||
if (activeLegendType.value === 'adoption') return 'Adoption Status'
|
||||
|
|
@ -714,8 +714,48 @@ provide('getActivePinColor', (properties) => {
|
|||
const strat = getColorStrategy(activeTab.value?.colorStrategy ?? 'languageFamily')
|
||||
return strat?.getColor?.(properties) ?? '#1a1a1a'
|
||||
})
|
||||
// Card d1336834 — flat-tab nodes carry a placeholder ['==', ['get','_flat_filter'], key]
|
||||
// filter that never matches real features, so we synthesize the real match
|
||||
// expression for the camera here. Tree-tab nodes carry usable Mapbox filters
|
||||
// already (fam:/lang:/dial: + region:/block:/country: + bloc:/cluster:/pg:/pgic:),
|
||||
// so this returns null for them and zoomToLegendRow falls back to node.filter.
|
||||
function _buildFlatLegendMatchExpr(node, legendType) {
|
||||
if (!node || !legendType) return null
|
||||
const key = node.id
|
||||
if (legendType === 'prayer') {
|
||||
const v = ['to-number', ['get', 'peoplePraying']]
|
||||
if (key === 'noPrayer') return ['==', v, 0]
|
||||
if (key === 'fullPrayer') return ['>=', v, FULL_PRAYER_THRESHOLD]
|
||||
if (key === 'hasPrayer') return ['all', ['>', v, 0], ['<', v, FULL_PRAYER_THRESHOLD]]
|
||||
} else if (legendType === 'engagement') {
|
||||
if (key === 'hasEngagement') return ['==', ['get', 'engagementStatus'], true]
|
||||
if (key === 'notEngaged') return ['==', ['get', 'engagementStatus'], false]
|
||||
} else if (legendType === 'adoption') {
|
||||
if (key === 'hasAdoption') return ['==', ['get', 'adoptionStatus'], true]
|
||||
if (key === 'notAdopted') return ['==', ['get', 'adoptionStatus'], false]
|
||||
} else if (legendType === 'religion') {
|
||||
// expectedValue is a family letter (C/M/H/B/E/J/S/N/O/U). Match first char.
|
||||
return ['==', ['slice', ['coalesce', ['get', 'religion'], ''], 0, 1], key]
|
||||
}
|
||||
return null
|
||||
}
|
||||
function onSemanticTreeSelect(node) {
|
||||
const m = map.value
|
||||
// Card d1336834 — uniform legend-row camera: any non-null row click moves
|
||||
// the camera to fit its scope, derived from live pin coords. Flat-tab nodes
|
||||
// need the real match expression synthesized (their node.filter is a
|
||||
// _flat_filter placeholder); tree-tab nodes use node.filter directly.
|
||||
if (node) {
|
||||
const flatExpr = _isFlatTab.value
|
||||
? _buildFlatLegendMatchExpr(node, activeLegendType.value)
|
||||
: null
|
||||
// doxa-regions tab → fit polygon (sparse regions like Oceania should
|
||||
// zoom TO the region, not OUT to span its few scattered pins).
|
||||
const isRegionsTab = activeTab.value?.id === 'doxa-regions'
|
||||
const flyOpts = flatExpr ? { matchExpr: flatExpr } : {}
|
||||
if (isRegionsTab) flyOpts.regionPolygonSource = 'regions'
|
||||
mapFly.zoomToLegendRow?.(node, Object.keys(flyOpts).length ? flyOpts : undefined)
|
||||
}
|
||||
if (!node) {
|
||||
// Flat tabs: clear filter directly
|
||||
if (_isFlatTab.value) {
|
||||
|
|
@ -782,8 +822,12 @@ function onSemanticTreeSelect(node) {
|
|||
if (m && m.getLayer('language-family-pins') && node.filter) {
|
||||
// Drop any prior setFilter from a previous tab interaction so all pins render.
|
||||
try { m.setFilter('language-family-pins', null) } catch (_) {}
|
||||
_syncHitboxFilter(m, null)
|
||||
// Dim via case expression — matched pins at 1.0, others at 0.06 (matches research-map's
|
||||
// Mirror the match expression onto the invisible hitbox layer so
|
||||
// dimmed (opacity-0) pins are not selectable. Without this the
|
||||
// hitbox stays full-size for every pin and the user can tap an
|
||||
// apparent empty area to select a hidden pin.
|
||||
_syncHitboxFilter(m, node.filter)
|
||||
// Dim via case expression — matched pins at 1.0, others at 0 (matches
|
||||
// applyDimFilter values).
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', [
|
||||
'case', node.filter, 1, 0
|
||||
|
|
@ -1129,6 +1173,18 @@ function clearAllHighlights(m) {
|
|||
if (mapStore.selectedFamily) mapStore.selectFamily(null)
|
||||
if (mapStore.selectedLanguage) mapStore.selectLanguage(null)
|
||||
if (mapStore.selectedDialect) mapStore.selectDialect(null)
|
||||
// Flat-tab rows (prayer/engagement/adoption/religion) and tree rows are
|
||||
// tracked separately on pplrInstance.selection — clearing the uiStore
|
||||
// filters above does NOT cascade into pplrInstance, so without this the
|
||||
// legend row stays visually selected while the map filter clears, leaving
|
||||
// the UI inconsistent. Reset the legend-row selection directly so a clear
|
||||
// anywhere (toggle-off, empty-map tap, programmatic) leaves both the map
|
||||
// and the legend in matching states.
|
||||
try {
|
||||
if (pplrInstance && pplrInstance.selection && pplrInstance.selection.value) {
|
||||
pplrInstance.selection.value = null
|
||||
}
|
||||
} catch (_) { /* no-op */ }
|
||||
}
|
||||
|
||||
// Same-tick dedupe — a single legend-row click fires applyDimFilter TWICE on
|
||||
|
|
@ -1243,9 +1299,10 @@ function applyDimFilter(detail) {
|
|||
}
|
||||
} else {
|
||||
// Family / language / prayer / engagement / adoption: keep all pins
|
||||
// rendered (no source-filter) but dim the non-matching ones.
|
||||
// rendered on the visible pins layer (no source-filter so the bucket
|
||||
// can still be styled / sorted), but dim the non-matching ones via a
|
||||
// circle-opacity `case` expression.
|
||||
m.setFilter('language-family-pins', null)
|
||||
_syncHitboxFilter(m, null)
|
||||
|
||||
// Build the per-kind match expression. Prayer compares against a numeric
|
||||
// bucket (noPrayer=0 / hasPrayer=(0,FULL_PRAYER_THRESHOLD) / fullPrayer
|
||||
|
|
@ -1322,6 +1379,12 @@ function applyDimFilter(detail) {
|
|||
'case', matchExpr, 1, 0
|
||||
])
|
||||
}
|
||||
// Restrict the invisible hitbox layer to the matched bucket so dimmed
|
||||
// (opacity-0) pins are no longer hit-testable. Without this the user
|
||||
// can tap on apparent empty space and select a hidden pin, and the
|
||||
// generic map-click handler sees a queryRenderedFeatures hit on the
|
||||
// hidden pin and skips its own clear path inconsistently.
|
||||
_syncHitboxFilter(m, matchExpr)
|
||||
mapLayers.syncGlowFilter(matchExpr)
|
||||
if (m.getLayer('regions-fill')) {
|
||||
m.setPaintProperty('regions-fill', 'fill-opacity', 0.20)
|
||||
|
|
@ -1628,30 +1691,10 @@ async function onMapReady(normalizedPeopleGroups) {
|
|||
schedule(() => { void ensureRegionsLoaded() })
|
||||
}
|
||||
|
||||
// Click-on-empty-map clears any active legend-row filter so dimmed pins
|
||||
// restore (qa: 2026-05-02 iter-12 — user reported clicking a row dimmed
|
||||
// non-matching pins as expected, but clicking off the row left the dim
|
||||
// state stuck). Mapbox layer-specific handlers fire BEFORE this generic
|
||||
// one, so we just check queryRenderedFeatures for the relevant interactive
|
||||
// layers; if empty, the click landed on empty map.
|
||||
map.value.on('click', (e) => {
|
||||
const interactiveLayers = ['language-family-pins', 'regions-fill']
|
||||
.filter(id => map.value.getLayer(id))
|
||||
const hits = interactiveLayers.length
|
||||
? map.value.queryRenderedFeatures(e.point, { layers: interactiveLayers })
|
||||
: []
|
||||
if (hits.length) return
|
||||
const hadFilter =
|
||||
uiStore.prayerFilter || uiStore.engagementFilter || uiStore.adoptionFilter || uiStore.religionFilter ||
|
||||
mapStore.selectedFamily || mapStore.selectedLanguage || mapStore.selectedDialect ||
|
||||
mapStore.selectedRegion
|
||||
if (!hadFilter) return
|
||||
if (uiStore.prayerFilter) uiStore.setPrayerFilter?.(null)
|
||||
if (uiStore.engagementFilter) uiStore.setEngagementFilter?.(null)
|
||||
if (uiStore.adoptionFilter) uiStore.setAdoptionFilter?.(null)
|
||||
if (uiStore.religionFilter) uiStore.setReligionFilter?.(null)
|
||||
clearAllHighlights(map.value)
|
||||
})
|
||||
// Empty-map-click intentionally does nothing. The legend selection is
|
||||
// sticky — only the same row re-tapped or the legend close button clears
|
||||
// it. (Spec change from earlier "click-off clears" behavior; users found
|
||||
// the auto-clear surprising when they tapped the map just to inspect.)
|
||||
|
||||
mapStore.setMapReady(mapId)
|
||||
appReady.value = true
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const LegendMobile = defineAsyncComponent(() => import('@map/components/LegendMo
|
|||
import SemanticTreeLegend from '@map/components/SemanticTreeLegend.vue'
|
||||
import { createPplrInstance, provideInstance } from '@map/composables/usePplrInstance.js'
|
||||
import { useLegendData as useFlatLegendData } from '@map/composables/useLegendData.js'
|
||||
import { useMapFly } from '@map/composables/useMapFly.js'
|
||||
import SideMenuDrawer from '@map/components/SideMenuDrawer.vue'
|
||||
// ─── Map control components ───────────────────────────────────────────────────
|
||||
// Each button is imported individually so the profile can compose any subset
|
||||
|
|
@ -328,6 +329,15 @@ function onSemanticTreeSelect(node) {
|
|||
if (t === 'prayer') uiStore.setPrayerFilter(node.id)
|
||||
else if (t === 'engagement') uiStore.setEngagementFilter(node.id)
|
||||
else if (t === 'adoption') uiStore.setAdoptionFilter(node.id)
|
||||
|
||||
// Card d1336834 — uniform legend-row camera. node.filter is a _flat_filter
|
||||
// placeholder for simple-map's prayer/engagement/adoption tabs, so we
|
||||
// synthesize the real match expression via buildMatchExpr (same one
|
||||
// applyLegendFilter uses for the dim path).
|
||||
const matchExpr = buildMatchExpr(t, node.id)
|
||||
if (matchExpr) {
|
||||
mapFly.zoomToLegendRow?.(node, { matchExpr })
|
||||
}
|
||||
}
|
||||
|
||||
const mapContainer = ref(null)
|
||||
|
|
@ -405,6 +415,15 @@ const mapLayers = useMapLayers({
|
|||
getNormalizedPeopleGroups: () => mapData.normalizedPeopleGroups.value || []
|
||||
})
|
||||
|
||||
// ─── Fly composable (card d1336834 — uniform legend-row camera) ─────────────
|
||||
const mapFly = useMapFly({
|
||||
getMap: () => map.value,
|
||||
mapId,
|
||||
mapStore,
|
||||
defaultCenter: [20, 10],
|
||||
defaultZoom: 1.8
|
||||
})
|
||||
|
||||
// ─── GO marker (selected pin highlight) ───────────────────────────────────────
|
||||
const selectedPin = useSelectedPin({ getMap: () => map.value })
|
||||
|
||||
|
|
@ -721,6 +740,16 @@ function switchTab(tabId) {
|
|||
|
||||
const ACTIVE_LAYER = 'language-family-pins-active'
|
||||
const PULSE_LAYER = 'language-family-pins-pulse'
|
||||
const HITBOX_LAYER = 'language-family-pins-hitbox'
|
||||
|
||||
// Mirror a Mapbox filter expression to the invisible hitbox layer so dimmed
|
||||
// (low-opacity) pins are no longer hit-testable. Pass `null` to restore the
|
||||
// full-dataset hitbox (every pin selectable again).
|
||||
function _syncHitboxFilter(m, filter) {
|
||||
if (m?.getLayer(HITBOX_LAYER)) {
|
||||
try { m.setFilter(HITBOX_LAYER, filter) } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
let _pulseRaf = null
|
||||
let _pulsePhase = 0
|
||||
|
|
@ -949,6 +978,7 @@ function applyLegendFilter(filterType, filterKey) {
|
|||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_syncHitboxFilter(m, null)
|
||||
mapLayers.syncGlowFilter(null)
|
||||
clearActiveLayer()
|
||||
return
|
||||
|
|
@ -960,6 +990,7 @@ function applyLegendFilter(filterType, filterKey) {
|
|||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_syncHitboxFilter(m, null)
|
||||
mapLayers.syncGlowFilter(null)
|
||||
clearActiveLayer()
|
||||
return
|
||||
|
|
@ -976,6 +1007,10 @@ function applyLegendFilter(filterType, filterKey) {
|
|||
['case', matchExpr, 1, 0.06])
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity',
|
||||
['case', matchExpr, 1, 0.06])
|
||||
// Restrict the invisible hitbox layer to the matched bucket so dimmed
|
||||
// (low-opacity) pins are no longer hit-testable. Without this the user
|
||||
// can tap an apparent empty area to select a near-invisible pin.
|
||||
_syncHitboxFilter(m, matchExpr)
|
||||
mapLayers.syncGlowFilter(matchExpr)
|
||||
return
|
||||
}
|
||||
|
|
@ -1216,7 +1251,11 @@ onBeforeUnmount(() => {
|
|||
MapToolbar is a layout-only shell (positioned column + slot).
|
||||
Binding :map="map" here guarantees that in a multi-map profile
|
||||
these buttons operate ONLY on their own map instance. -->
|
||||
<MapToolbar v-if="appReady">
|
||||
<!-- :has-geocoder mirrors the FEATURES.geocoder flag so the toolbar
|
||||
collapses its mobile top offset when no search pill is rendered.
|
||||
When the flag is flipped back on, the toolbar reserves space
|
||||
below the geocoder again with no further changes here. -->
|
||||
<MapToolbar v-if="appReady" :has-geocoder="FEATURES.geocoder">
|
||||
<ZoomInButton :map="map" :is-dark="isDark" />
|
||||
<ZoomOutButton :map="map" :is-dark="isDark" />
|
||||
<LocationButton :map="map" :is-dark="isDark" />
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
BUILD-READY: 610deb7
|
||||
|
||||
## Changes
|
||||
|
||||
### Feat: Redesign ShareButton for non-technical users
|
||||
|
||||
**Commit:** 610deb7
|
||||
**Files:**
|
||||
- src/components/map-controls/ShareButton.vue (complete rewrite)
|
||||
|
||||
Complete UX redesign of the share popover modeled on YouTube, Spotify, Canva,
|
||||
and Google Maps share patterns. Two-tab layout replaces the old single-panel:
|
||||
|
||||
**Tab 1: Share Link (default)**
|
||||
- Clean URL in a styled read-only display box
|
||||
- Large "Copy Link" CTA button with clipboard copy + checkmark feedback
|
||||
- Social share row: email (mailto) and WhatsApp share links
|
||||
|
||||
**Tab 2: Add to Your Website**
|
||||
- Friendly plain-English description (never uses the word "iframe")
|
||||
- Small / Medium / Large size-preset pills (400/600/800px height)
|
||||
- Styled code box that updates live when preset changes
|
||||
- "Copy Code" button with same Copied! feedback
|
||||
|
||||
**UX polish:**
|
||||
- Pop-in animation on open
|
||||
- Header with "Share" title and X close button
|
||||
- Box-with-arrow-up share icon (iOS/Material standard)
|
||||
- Full dark/light theme via useShadowStyles
|
||||
- Mobile-responsive: wider popover below button
|
||||
|
||||
**Build verified:** both doxa-research-map.js and doxa-simple-map.js compile.
|
||||
|
||||
## Test plan
|
||||
- Click share icon -- popover opens with "Share Link" tab active
|
||||
- Tab switching: click "Add to Your Website" -- tab content changes smoothly
|
||||
- Copy Link: click button -- clipboard gets URL, button shows checkmark + "Copied!" for 2s
|
||||
- Email icon: opens mailto: link with map URL in body
|
||||
- WhatsApp icon: opens wa.me share with map URL
|
||||
- Switch to embed tab -- size pills default to "Medium"
|
||||
- Click "Small" pill -- code box updates to height="400"
|
||||
- Click "Large" pill -- code box updates to height="800"
|
||||
- Copy Code: click button -- clipboard gets embed snippet, "Copied!" feedback
|
||||
- Click outside popover -- closes
|
||||
- Press Escape -- closes
|
||||
- Re-click share button -- toggles closed
|
||||
- Toggle dark/light theme -- all popover styles adapt
|
||||
- Mobile viewport -- popover wider, positioned below button, no overflow
|
||||
- Paste embed snippet into test HTML -- map renders at chosen size
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "1040-maps",
|
||||
"dependencies": {
|
||||
"jspdf": "^4.2.1",
|
||||
"pinia": "^2.3.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"vue": "^3.5.20",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"world-atlas": "^2.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"terser": "^5.46.0",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@intlify/core-base": ["@intlify/core-base@9.14.5", "", { "dependencies": { "@intlify/message-compiler": "9.14.5", "@intlify/shared": "9.14.5" } }, "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA=="],
|
||||
|
||||
"@intlify/message-compiler": ["@intlify/message-compiler@9.14.5", "", { "dependencies": { "@intlify/shared": "9.14.5", "source-map-js": "^1.0.2" } }, "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ=="],
|
||||
|
||||
"@intlify/shared": ["@intlify/shared@9.14.5", "", {}, "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.35", "", { "dependencies": { "@babel/parser": "^7.29.3", "@vue/shared": "3.5.35", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.35", "", { "dependencies": { "@vue/compiler-core": "3.5.35", "@vue/shared": "3.5.35" } }, "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.35", "", { "dependencies": { "@babel/parser": "^7.29.3", "@vue/compiler-core": "3.5.35", "@vue/compiler-dom": "3.5.35", "@vue/compiler-ssr": "3.5.35", "@vue/shared": "3.5.35", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.35", "", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/shared": "3.5.35" } }, "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.35", "", { "dependencies": { "@vue/shared": "3.5.35" } }, "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.35", "", { "dependencies": { "@vue/reactivity": "3.5.35", "@vue/shared": "3.5.35" } }, "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.35", "", { "dependencies": { "@vue/reactivity": "3.5.35", "@vue/runtime-core": "3.5.35", "@vue/shared": "3.5.35", "csstype": "^3.2.3" } }, "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.35", "", { "dependencies": { "@vue/compiler-ssr": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "vue": "3.5.35" } }, "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.35", "", {}, "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||
|
||||
"jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pinia": ["pinia@2.3.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||
|
||||
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||
|
||||
"terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vite-plugin-css-injected-by-js": ["vite-plugin-css-injected-by-js@3.5.2", "", { "peerDependencies": { "vite": ">2.0.0-0" } }, "sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ=="],
|
||||
|
||||
"vue": ["vue@3.5.35", "", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-i18n": ["vue-i18n@9.14.5", "", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
|
||||
|
||||
"world-atlas": ["world-atlas@2.0.2", "", {}, "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Zoom-Out: The legend IS the app
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Session:** 85e9db765e8dccad (coordinator, 1040-maps)
|
||||
**Reframing question answered:** Why does every build wave end with legend fixes?
|
||||
|
||||
---
|
||||
|
||||
## Core tension
|
||||
|
||||
The 1040-maps legend is not a sidebar UI element — it is the primary interaction surface of every map, and it is being treated as a secondary concern that gets patched after each feature wave.
|
||||
|
||||
## What the local work keeps doing about it
|
||||
|
||||
Every build wave follows the same arc: land a major feature (research map port, modern styling, prayer ripple, geo-steward migration), then spend 5-15 iterative commits fixing legend behavior — height, collapse state, mobile parity, tab memory, caret alignment, scroll overflow, pill styling. The commit history on `maps` tells the story: "Research map iter-1" through "iter-21" are overwhelmingly legend fixes. The current session immediately surfaced three more legend issues from a single tablet test. The predecessor session's geo-steward migration landed 65 files in one night, but the *next* session's first QA pass is... legend height on tablet.
|
||||
|
||||
## The reframe
|
||||
|
||||
The legend is not a component that accompanies the map. It is the app's primary navigation, selection, filtering, and detail-display surface. On mobile/tablet it occupies 30-90% of viewport. It has a tri-state height machine, a mode switcher (prayer/detail), per-tab tier memory, collapsed-pill mode, semantic tree rendering, flat-row rendering, and a detail panel that swaps in-place. It is, by complexity and interaction surface area, a larger application than the Mapbox canvas it sits beside.
|
||||
|
||||
Treating it as "the legend" — a secondary annotation layer — causes two downstream failures:
|
||||
|
||||
1. **No acceptance criteria before build.** Features land without specifying legend behavior, so legend bugs are discovered post-hoc via QA rather than prevented by spec.
|
||||
2. **No tablet/mobile-first design.** The legend is designed desktop-first and then reactively patched for smaller viewports, despite mobile/tablet being the primary consumption context for maps serving disciples in high-security countries.
|
||||
|
||||
## What changes if we accept the reframe
|
||||
|
||||
- **Legend behavior becomes a first-class section in every feature spec.** Before any builder spawns, the card must answer: what does the legend do when this feature activates? What height state? What mode? What happens on mobile?
|
||||
- **Tablet is the reference viewport, not desktop.** The Driver just tested on tablet and found it "works pretty good" — that's the target device. Desktop is the secondary viewport. Design legend from tablet up, not desktop down.
|
||||
- **The legend gets its own QA checklist.** A standing 5-item gate: (1) content-fit height, (2) no bottom underlap, (3) detail-mode collapses legend, (4) touch targets >= 44px, (5) collapsed pill accessible on all tabs.
|
||||
- **The geo-steward migration (sitting unpushed in Map-Framework) must include legend integration spec before merging.** 65 files of backend + components without a legend spec = another round of post-hoc legend fixes.
|
||||
- **Consider extracting legend into its own composable with explicit viewport contracts** rather than patching CSS per-map. The current approach (SemanticTreeLegend.vue at 838 lines + uiStore legend state machine) is one component doing too many jobs across too many viewport assumptions.
|
||||
|
||||
## Reframing question for the next session
|
||||
|
||||
If the legend is the app, should it have its own store, its own viewport-contract tests, and its own QA gate — or is the current "patch after each wave" cost acceptable given the shipping velocity it enables?
|
||||
|
|
@ -62,9 +62,23 @@ useShadowStyles(`
|
|||
.legend-container:not(.legend-dark) .legend-collapse-caret:hover{background:rgba(59,70,61,0.12);border-color:#3b463d;color:#3b463d;}
|
||||
|
||||
/* Detail-mode close button — top-right of the card */
|
||||
.detail-close-btn{position:absolute;top:10px;right:10px;background:none;border:none;padding:4px;cursor:pointer;color:#888;display:flex;align-items:center;justify-content:center;z-index:2;transition:color 0.2s ease;}
|
||||
.detail-close-btn{position:absolute;top:10px;right:10px;background:none;border:none;padding:4px;cursor:pointer;color:#888;display:flex;align-items:center;justify-content:center;z-index:20;transition:color 0.2s ease;}
|
||||
.detail-close-btn:hover{color:#333;}
|
||||
|
||||
/* Detail-mode collapse caret — top-left of the card, mirrors the inline
|
||||
.legend-collapse-caret used by the data-view title row. Sits at z-index
|
||||
above the PeopleGroupDetail sticky header (.detail-header z-index:10) so
|
||||
it is always tappable even when the detail panel scrolls. The button is
|
||||
only rendered in the detail-mode template branch (see <template> below). */
|
||||
.detail-collapse-btn{position:absolute;top:10px;left:10px;z-index:20;background:transparent;border:none;color:transparent;cursor:pointer;width:28px;height:28px;padding:0;outline:none;-webkit-tap-highlight-color:transparent;}
|
||||
.detail-collapse-btn:focus{outline:none;}
|
||||
/* Inside the detail panel: leave a 32px left gutter on the title text so the
|
||||
absolute-positioned .detail-collapse-btn at top:10px/left:10px does not
|
||||
overlap the people-group name. Targets the .pg-name-lead inside any
|
||||
.people-group-detail (which itself is rendered as a child of
|
||||
.legend-content). Right gutter clears the .detail-close-btn the same way. */
|
||||
.legend-container .people-group-detail .pg-name-lead{padding-left:28px;padding-right:32px;}
|
||||
|
||||
/* ── Content — no padding; left/right AND top/bottom are handled by the
|
||||
LegendRows grid (caret + trailing columns + .lrg-items padding 12px 0).
|
||||
Double-padding here would push the collapsed title row out of the
|
||||
|
|
@ -263,15 +277,21 @@ const legendTitle = computed(() => {
|
|||
// NOTE: speedLabel / flyInterval / showAutoFlyButton predicate moved to
|
||||
// LegendTools.vue along with the toolbar UI.
|
||||
|
||||
// Toggle legend collapse — blocked in detail mode so X button click can't accidentally collapse
|
||||
// Toggle legend collapse — always honors the user gesture, including while a
|
||||
// people-group is selected (detail mode). The collapse caret + reopen pill
|
||||
// are the only intentional collapse affordances; the detail-close-btn (X)
|
||||
// clears the selection separately and never triggers a collapse.
|
||||
function toggleCollapse() {
|
||||
if (legendMode.value === 'detail') return
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
// Auto-expand when pin is clicked and legend enters detail mode
|
||||
watch(legendMode, (mode) => {
|
||||
if (mode === 'detail' && isCollapsed.value) {
|
||||
// Auto-expand only on the FIRST transition into detail mode so the user sees
|
||||
// what they just picked. Tracking the previous mode lets the user manually
|
||||
// collapse afterwards (and have that stick) — legendMode does not change
|
||||
// again until selection is cleared, so this watcher will not refire and
|
||||
// stomp on the user's choice.
|
||||
watch(legendMode, (mode, prevMode) => {
|
||||
if (mode === 'detail' && prevMode !== 'detail' && isCollapsed.value) {
|
||||
isCollapsed.value = false
|
||||
}
|
||||
})
|
||||
|
|
@ -341,6 +361,16 @@ onBeforeUnmount(() => {
|
|||
|
||||
<!-- Expanded legend card -->
|
||||
<div v-else :class="['legend-container', { 'legend-dark': isDark }]">
|
||||
<!-- Detail-mode collapse caret — only rendered when a people-group is
|
||||
selected (LegendRows + its #title-caret slot are not on screen in
|
||||
this branch). Lives at top-left, mirroring the caret position in
|
||||
data-view, so collapse is always one tap away regardless of mode. -->
|
||||
<button
|
||||
v-if="legendMode === 'detail'"
|
||||
class="detail-collapse-btn"
|
||||
@click.stop="toggleCollapse"
|
||||
:aria-label="t('aria.toggleLegend')"
|
||||
></button>
|
||||
<button
|
||||
v-if="legendMode === 'detail'"
|
||||
class="detail-close-btn"
|
||||
|
|
|
|||
|
|
@ -49,6 +49,14 @@ useShadowStyles(`
|
|||
.legend-mobile-sheet.sheet-dark .mobile-collapse-caret{background:rgba(110,118,129,0.15);border-color:#30363d;color:#8b949e;box-shadow:none;}
|
||||
.legend-mobile-sheet.sheet-dark .mobile-collapse-caret:hover{color:#c9d1d9;background:rgba(59,70,61,0.18);border-color:#73A17F;}
|
||||
|
||||
/* Detail-mode collapse caret — top-left of the sheet when in detail mode
|
||||
and the sheet is NOT collapsed. Mirrors the .detail-close-btn placement
|
||||
on the right; gives the user an always-visible, finger-sized tap target
|
||||
to collapse the sheet without having to find the 12px drag-strip. z-index
|
||||
above .sheet-drag-strip (z:2) so the strip's @click doesn't steal taps. */
|
||||
.detail-collapse-btn-mobile{position:absolute;top:10px;left:12px;background:transparent;border:none;padding:0;cursor:pointer;color:transparent;width:28px;height:28px;z-index:3;outline:none;-webkit-tap-highlight-color:transparent;}
|
||||
.detail-collapse-btn-mobile:focus{outline:none;}
|
||||
|
||||
/* Detail-mode close X — top-right corner */
|
||||
.detail-close-btn{position:absolute;top:10px;right:12px;background:none;border:none;padding:4px;cursor:pointer;color:#666;display:flex;align-items:center;justify-content:center;z-index:3;transition:color 0.2s ease;}
|
||||
.detail-close-btn:hover{color:#333;}
|
||||
|
|
@ -57,7 +65,7 @@ useShadowStyles(`
|
|||
(caret + centered title) but for PeopleGroupDetail mode. Renders only
|
||||
when state=collapsed AND mode=detail (template v-if). Sits below the
|
||||
12px drag-strip so the strip's tap+drag still works to expand. */
|
||||
.collapsed-detail-footer{position:absolute;top:12px;left:0;right:0;height:36px;display:flex;align-items:center;justify-content:center;gap:8px;padding:0 16px;cursor:pointer;background:transparent;}
|
||||
.collapsed-detail-footer{position:absolute;top:12px;left:0;right:0;height:36px;display:flex;align-items:center;justify-content:center;gap:8px;padding:0 16px;cursor:pointer;background:transparent;z-index:4;}
|
||||
.collapsed-detail-title{font:600 13px system-ui,sans-serif;letter-spacing:0.02em;line-height:1.4;color:#1f2328;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.legend-mobile-sheet.sheet-dark .collapsed-detail-title{color:#F3F3F1;}
|
||||
|
||||
|
|
@ -342,7 +350,7 @@ const treeTitleForMobile = computed(() => {
|
|||
return 'Languages'
|
||||
})
|
||||
const treeColumnLabelForMobile = computed(() => {
|
||||
if (props.legendType === 'religion') return 'World Religions'
|
||||
if (props.legendType === 'religion') return 'UUPG Religions'
|
||||
if (props.legendType === 'prayer') return 'Prayer Status'
|
||||
if (props.legendType === 'engagement') return 'Engagement Status'
|
||||
if (props.legendType === 'adoption') return 'Adoption Status'
|
||||
|
|
@ -534,6 +542,17 @@ onBeforeUnmount(() => {
|
|||
grid/flex align-items:center — matching desktop's slot pattern.
|
||||
No more absolute positioning or padding hacks.) -->
|
||||
|
||||
<!-- Detail-mode collapse caret — top-left, mirrors the close X on the
|
||||
right. Provides an always-visible finger-sized affordance to
|
||||
collapse the sheet when a people-group is selected. The 12px
|
||||
drag-strip alone is too small to reliably hit on touch. -->
|
||||
<button
|
||||
v-if="legendMode === 'detail' && legendState !== 'collapsed'"
|
||||
class="detail-collapse-btn-mobile"
|
||||
@click.stop="handleCaretClick"
|
||||
:aria-label="t('aria.toggleLegend')"
|
||||
></button>
|
||||
|
||||
<!-- Detail-mode close X — absolutely positioned top-right.
|
||||
Hidden when collapsed so the footer is clean (the user can re-expand
|
||||
via the collapsed-detail-footer caret to reach the X). -->
|
||||
|
|
|
|||
|
|
@ -1,39 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/src/components/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: components
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
- src
|
||||
- components
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons: []
|
||||
svg-candidate-score: 0
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.451950+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:39.360475+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- legends-mapcontrolscomponent-vue
|
||||
- shared-vue-components
|
||||
- new-legendmobilesheet-vue
|
||||
- mobile-legend-drawer
|
||||
- rendering-primitive-shared
|
||||
voice-memo-shape: false
|
||||
word-count: 271
|
||||
---
|
||||
|
||||
# template/src/components/
|
||||
|
||||
> **Shared Vue components used by app-profiles.** Catalog and contracts live in [`/docs/components/`](../../../docs/components/).
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* SemanticTreeLegend — reusable hierarchical legend for any one-to-many semantic tree.
|
||||
*
|
||||
* EXACT CLONE of /Map-Framework/05-apps/PPLR-MAPS/PPLR-validate-data-mfe/src/components/SemanticTreeLegend.vue
|
||||
* EXACT CLONE of the upstream PPLR module
|
||||
* per user directive: "do a direct clone, only shave the API." Only difference vs.
|
||||
* upstream is the <style scoped> block converted to useShadowStyles() so the CSS
|
||||
* lands inside doxa-research-mfe's shadow root.
|
||||
|
|
@ -436,7 +436,7 @@ useShadowStyles(`
|
|||
font: 700 11px ui-monospace, monospace;
|
||||
color: #8b949e; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
/* Center-aligned UPGs/Pop column header (qa: 2026-05-02 — user explicitly
|
||||
/* Center-aligned UUPGs/Pop column header (qa: 2026-05-02 — user explicitly
|
||||
requested center over right-align so the header label sits on the same
|
||||
x-center as the numeric value below; reduces eye drift across rows). */
|
||||
.stl-hdr-num {
|
||||
|
|
@ -767,9 +767,9 @@ useShadowStyles(`
|
|||
<span class="stl-hdr-name">{{ columnLabel || (hideTabs ? title : (tabList[activeTab]?.label || 'Name')) }}</span>
|
||||
<button v-if="hasCount" class="stl-hdr-num"
|
||||
:class="{ active: sortBy === 'count' }"
|
||||
title="Sort by UPGs"
|
||||
title="Sort by UUPGs"
|
||||
@click="clickSortHeader('count')">
|
||||
UPGs
|
||||
UUPGs
|
||||
<span v-if="sortBy === 'count'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
|
||||
</button>
|
||||
<button v-if="hasPop" class="stl-hdr-num"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
<!--
|
||||
FullscreenButton.vue — Toggles browser fullscreen for the map host element.
|
||||
FullscreenButton.vue — Toggles fullscreen for the map host element.
|
||||
|
||||
The fullscreen request targets the Custom Element's host node so the entire
|
||||
widget goes fullscreen (not just the inner div). Falls back to
|
||||
document.documentElement if the shadow root host isn't available.
|
||||
Two strategies depending on platform capability:
|
||||
|
||||
1. Standard Fullscreen API (desktop + Android Chrome + iPadOS w/ trackpad)
|
||||
— calls requestFullscreen / webkitRequestFullscreen on the custom-element
|
||||
host. Listens to fullscreenchange to stay in sync with Esc.
|
||||
|
||||
2. CSS pseudo-fullscreen fallback (iOS Safari / iPhone)
|
||||
— iOS Safari does NOT implement requestFullscreen on arbitrary
|
||||
elements (only on <video>). Attempting it silently fails. We instead
|
||||
apply position:fixed inset:0 z-index:2147483647 to the custom-element
|
||||
host so the map fills the viewport. Tap again to release.
|
||||
|
||||
Listens to the native `fullscreenchange` event to keep `isFullscreen` in sync
|
||||
when the user presses Escape to exit.
|
||||
when the user presses Escape to exit (standard-API path only).
|
||||
|
||||
Props:
|
||||
mapContainer — ref to the map canvas div (used to walk up to shadow host)
|
||||
|
|
@ -31,31 +39,318 @@ const emit = defineEmits(['change'])
|
|||
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
// Track the shadow root we attached the change listener to (if any) so we
|
||||
// can remove the listener from the same target on unmount.
|
||||
let shadowRootRef = null
|
||||
|
||||
// Saved inline-style snapshot for the pseudo-fullscreen path so we can
|
||||
// restore the host element exactly to its pre-toggle state.
|
||||
let pseudoSavedCssText = null
|
||||
let pseudoHostEl = null
|
||||
// Saved body / documentElement inline values so we can restore them
|
||||
// exactly on exit (avoids stomping app-level inline styles). The page
|
||||
// host may have its own scroller (Nuxt commonly wraps content in a div
|
||||
// with its own overflow), so locking just `body.overflow` is not enough
|
||||
// — we also hard-lock html.overflow, body.position:fixed (with
|
||||
// preserved scroll offset), body.touchAction, and attach a passive:false
|
||||
// touchmove blocker on document while pseudo-fullscreen is active.
|
||||
let pseudoSavedBodyOverflow = null
|
||||
let pseudoSavedHtmlOverflow = null
|
||||
let pseudoSavedBodyPosition = null
|
||||
let pseudoSavedBodyTop = null
|
||||
let pseudoSavedBodyLeft = null
|
||||
let pseudoSavedBodyWidth = null
|
||||
let pseudoSavedBodyTouchAction = null
|
||||
let pseudoSavedHtmlTouchAction = null
|
||||
let pseudoSavedScrollY = 0
|
||||
let pseudoSavedScrollX = 0
|
||||
let pseudoTouchmoveBlocker = null
|
||||
let pseudoWheelBlocker = null
|
||||
|
||||
// Platform detection ──────────────────────────────────────────────────────
|
||||
// iOS Safari (iPhone + non-iPadOS-13+ iPad) cannot fullscreen arbitrary
|
||||
// elements. iPadOS 13+ reports as Mac, but Safari there still rejects
|
||||
// requestFullscreen() on non-<video> elements until the user is in Stage
|
||||
// Manager / external display. Feature-detecting `document.fullscreenEnabled`
|
||||
// catches the common case; the UA regex covers iPhones where the property
|
||||
// exists as `webkitFullscreenEnabled` but only honors <video>.
|
||||
function isStandardFullscreenSupported() {
|
||||
if (typeof document === 'undefined') return false
|
||||
// Some iPad UAs claim Mac but still lack working element-fullscreen.
|
||||
const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || ''
|
||||
const isiOS = /iPad|iPhone|iPod/.test(ua)
|
||||
|| (ua.includes('Mac') && typeof document !== 'undefined' && 'ontouchend' in document)
|
||||
if (isiOS) return false
|
||||
return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled)
|
||||
}
|
||||
|
||||
function resolveHost() {
|
||||
return props.mapContainer?.getRootNode?.()?.host ?? null
|
||||
}
|
||||
|
||||
// Cross-browser fullscreen helpers ────────────────────────────────────────
|
||||
function currentFullscreenElement() {
|
||||
const sr = props.mapContainer?.getRootNode?.()
|
||||
return (
|
||||
document.fullscreenElement
|
||||
|| document.webkitFullscreenElement
|
||||
|| (sr && sr instanceof ShadowRoot ? sr.fullscreenElement : null)
|
||||
|| null
|
||||
)
|
||||
}
|
||||
|
||||
function requestFs(el) {
|
||||
const fn = el.requestFullscreen || el.webkitRequestFullscreen
|
||||
if (!fn) return Promise.reject(new Error('Fullscreen API unavailable'))
|
||||
const result = fn.call(el)
|
||||
// webkitRequestFullscreen historically returned `undefined`, not a promise.
|
||||
return result && typeof result.then === 'function' ? result : Promise.resolve()
|
||||
}
|
||||
|
||||
function exitFs() {
|
||||
const fn = document.exitFullscreen || document.webkitExitFullscreen
|
||||
if (!fn) return Promise.reject(new Error('Fullscreen API unavailable'))
|
||||
const result = fn.call(document)
|
||||
return result && typeof result.then === 'function' ? result : Promise.resolve()
|
||||
}
|
||||
|
||||
// Pseudo-fullscreen (CSS-only) helpers ────────────────────────────────────
|
||||
// Used when the native API is unavailable (notably iOS Safari).
|
||||
function enterPseudoFullscreen() {
|
||||
const host = resolveHost()
|
||||
if (!host) return false
|
||||
pseudoHostEl = host
|
||||
pseudoSavedCssText = host.style.cssText
|
||||
host.style.position = 'fixed'
|
||||
host.style.top = '0'
|
||||
host.style.left = '0'
|
||||
host.style.right = '0'
|
||||
host.style.bottom = '0'
|
||||
host.style.width = '100vw'
|
||||
// Set 100vh first as a fallback, then 100dvh; browsers that don't
|
||||
// understand dvh ignore the second declaration and keep 100vh. dvh
|
||||
// accounts for the iOS Safari URL bar collapsing/expanding so the host
|
||||
// fills the dynamic viewport exactly.
|
||||
host.style.height = '100vh'
|
||||
host.style.height = '100dvh'
|
||||
host.style.zIndex = '2147483647'
|
||||
host.style.margin = '0'
|
||||
// Override any inherited rounded corners so the overlay fills the
|
||||
// viewport edge-to-edge — otherwise the underlying page bleeds through
|
||||
// at the corners.
|
||||
host.style.borderRadius = '0'
|
||||
// Let the map's own gestures (pan, pinch-zoom) flow into the canvas
|
||||
// unchanged; anything outside the canvas — i.e. padding zones around
|
||||
// the rendered map — must NOT trigger page scroll. The document-level
|
||||
// touch-action lock below handles the outside-canvas case.
|
||||
host.style.touchAction = 'pan-x pan-y pinch-zoom'
|
||||
|
||||
// Lock page scroll while pseudo-fullscreen is active. The embedding
|
||||
// page may have its own nested scroll containers (Nuxt wraps content
|
||||
// in a div with overflow:auto, host pages often have a sticky header
|
||||
// viewport, etc.), so `body.overflow:hidden` alone is not enough.
|
||||
// Combine: html+body overflow:hidden, body position:fixed (iOS
|
||||
// bullet-proof scroll lock — preserves scroll offset), body
|
||||
// touch-action:none (kills scroll-gesture interpretation at the
|
||||
// root), plus a passive:false touchmove listener that
|
||||
// preventDefault()s any touch outside the map host. wheel events
|
||||
// outside the host are blocked the same way for desktop.
|
||||
if (typeof document !== 'undefined') {
|
||||
pseudoSavedScrollY = window.scrollY || window.pageYOffset || 0
|
||||
pseudoSavedScrollX = window.scrollX || window.pageXOffset || 0
|
||||
const body = document.body
|
||||
const html = document.documentElement
|
||||
pseudoSavedBodyOverflow = body ? body.style.overflow : null
|
||||
pseudoSavedHtmlOverflow = html ? html.style.overflow : null
|
||||
pseudoSavedBodyPosition = body ? body.style.position : null
|
||||
pseudoSavedBodyTop = body ? body.style.top : null
|
||||
pseudoSavedBodyLeft = body ? body.style.left : null
|
||||
pseudoSavedBodyWidth = body ? body.style.width : null
|
||||
pseudoSavedBodyTouchAction = body ? body.style.touchAction : null
|
||||
pseudoSavedHtmlTouchAction = html ? html.style.touchAction : null
|
||||
|
||||
if (html) {
|
||||
html.style.overflow = 'hidden'
|
||||
html.style.touchAction = 'none'
|
||||
}
|
||||
if (body) {
|
||||
body.style.overflow = 'hidden'
|
||||
// position:fixed with negative top is the iOS-friendly scroll lock
|
||||
// — keeps the viewport pinned without losing the underlying
|
||||
// scroll position. We restore both on exit.
|
||||
body.style.position = 'fixed'
|
||||
body.style.top = `-${pseudoSavedScrollY}px`
|
||||
body.style.left = `-${pseudoSavedScrollX}px`
|
||||
body.style.width = '100%'
|
||||
body.style.touchAction = 'none'
|
||||
}
|
||||
|
||||
// Document-level touchmove / wheel blocker. Anything OUTSIDE the
|
||||
// shadow host (padding zones, scrollbars, gutters next to a
|
||||
// narrower map canvas) calls preventDefault. Inside the host, the
|
||||
// map's own gesture handlers stay intact because we never call
|
||||
// preventDefault on those events. passive:false is required for
|
||||
// preventDefault to actually cancel scroll on mobile.
|
||||
pseudoTouchmoveBlocker = (ev) => {
|
||||
if (!pseudoHostEl) return
|
||||
const target = ev.target
|
||||
// If the touch target is inside the host (the map embed) let it
|
||||
// through; otherwise kill the scroll gesture entirely.
|
||||
if (target && (target === pseudoHostEl || (pseudoHostEl.contains && pseudoHostEl.contains(target)))) {
|
||||
return
|
||||
}
|
||||
if (ev.cancelable) ev.preventDefault()
|
||||
}
|
||||
pseudoWheelBlocker = (ev) => {
|
||||
if (!pseudoHostEl) return
|
||||
const target = ev.target
|
||||
if (target && (target === pseudoHostEl || (pseudoHostEl.contains && pseudoHostEl.contains(target)))) {
|
||||
return
|
||||
}
|
||||
if (ev.cancelable) ev.preventDefault()
|
||||
}
|
||||
document.addEventListener('touchmove', pseudoTouchmoveBlocker, { passive: false, capture: true })
|
||||
document.addEventListener('wheel', pseudoWheelBlocker, { passive: false, capture: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function exitPseudoFullscreen() {
|
||||
if (!pseudoHostEl) return
|
||||
// Restore exactly — using cssText preserves any inline styles the page
|
||||
// applied before we touched the element.
|
||||
pseudoHostEl.style.cssText = pseudoSavedCssText || ''
|
||||
pseudoHostEl = null
|
||||
pseudoSavedCssText = null
|
||||
|
||||
// Restore page scroll. Empty string is the correct way to clear an
|
||||
// inline style; we fall back to '' if we somehow never captured a
|
||||
// value. Order matters here: clear the body position-lock styles
|
||||
// FIRST, then call window.scrollTo so the page returns to where the
|
||||
// user was before entering pseudo-fullscreen.
|
||||
if (typeof document !== 'undefined') {
|
||||
const body = document.body
|
||||
const html = document.documentElement
|
||||
if (body) {
|
||||
body.style.overflow = pseudoSavedBodyOverflow ?? ''
|
||||
body.style.position = pseudoSavedBodyPosition ?? ''
|
||||
body.style.top = pseudoSavedBodyTop ?? ''
|
||||
body.style.left = pseudoSavedBodyLeft ?? ''
|
||||
body.style.width = pseudoSavedBodyWidth ?? ''
|
||||
body.style.touchAction = pseudoSavedBodyTouchAction ?? ''
|
||||
}
|
||||
if (html) {
|
||||
html.style.overflow = pseudoSavedHtmlOverflow ?? ''
|
||||
html.style.touchAction = pseudoSavedHtmlTouchAction ?? ''
|
||||
}
|
||||
|
||||
// Detach the document-level scroll/touch blockers.
|
||||
if (pseudoTouchmoveBlocker) {
|
||||
document.removeEventListener('touchmove', pseudoTouchmoveBlocker, { capture: true })
|
||||
pseudoTouchmoveBlocker = null
|
||||
}
|
||||
if (pseudoWheelBlocker) {
|
||||
document.removeEventListener('wheel', pseudoWheelBlocker, { capture: true })
|
||||
pseudoWheelBlocker = null
|
||||
}
|
||||
|
||||
// Restore the scroll offset captured before we pinned the body.
|
||||
if (typeof window !== 'undefined' && (pseudoSavedScrollY || pseudoSavedScrollX)) {
|
||||
window.scrollTo(pseudoSavedScrollX, pseudoSavedScrollY)
|
||||
}
|
||||
}
|
||||
pseudoSavedBodyOverflow = null
|
||||
pseudoSavedHtmlOverflow = null
|
||||
pseudoSavedBodyPosition = null
|
||||
pseudoSavedBodyTop = null
|
||||
pseudoSavedBodyLeft = null
|
||||
pseudoSavedBodyWidth = null
|
||||
pseudoSavedBodyTouchAction = null
|
||||
pseudoSavedHtmlTouchAction = null
|
||||
pseudoSavedScrollY = 0
|
||||
pseudoSavedScrollX = 0
|
||||
}
|
||||
|
||||
function notifyMapResize() {
|
||||
// Give the browser one frame to apply layout, then nudge mapbox to
|
||||
// recompute its canvas size. Reading off the host element avoids any
|
||||
// direct coupling to the map instance.
|
||||
if (typeof window === 'undefined') return
|
||||
window.requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!document.fullscreenElement) {
|
||||
const host = props.mapContainer?.getRootNode()?.host ?? document.documentElement
|
||||
;(host.requestFullscreen?.() ?? Promise.resolve())
|
||||
.then(() => { isFullscreen.value = true; emit('change', true) })
|
||||
.catch(() => {
|
||||
document.documentElement.requestFullscreen?.()
|
||||
isFullscreen.value = true
|
||||
emit('change', true)
|
||||
})
|
||||
} else {
|
||||
document.exitFullscreen?.().then(() => {
|
||||
if (!isStandardFullscreenSupported()) {
|
||||
// Pseudo-fullscreen path
|
||||
if (isFullscreen.value) {
|
||||
exitPseudoFullscreen()
|
||||
isFullscreen.value = false
|
||||
emit('change', false)
|
||||
})
|
||||
} else {
|
||||
if (enterPseudoFullscreen()) {
|
||||
isFullscreen.value = true
|
||||
emit('change', true)
|
||||
}
|
||||
}
|
||||
notifyMapResize()
|
||||
return
|
||||
}
|
||||
|
||||
// Always re-read the live state — the cached `isFullscreen` ref can
|
||||
// fall out of sync if the user pressed Esc and the change event fired
|
||||
// on a target we weren't listening to (e.g. shadow root vs document).
|
||||
const active = currentFullscreenElement()
|
||||
if (!active) {
|
||||
const host = resolveHost() ?? document.documentElement
|
||||
requestFs(host)
|
||||
.then(() => { isFullscreen.value = true; emit('change', true) })
|
||||
.catch(() => {
|
||||
// Last-resort fallback: fullscreen the document element.
|
||||
requestFs(document.documentElement)
|
||||
.then(() => { isFullscreen.value = true; emit('change', true) })
|
||||
.catch(() => { /* user denied or API unavailable — leave state as-is */ })
|
||||
})
|
||||
} else {
|
||||
exitFs()
|
||||
.then(() => { isFullscreen.value = false; emit('change', false) })
|
||||
.catch(() => { /* exit failed — onFsChange will reconcile if state actually changed */ })
|
||||
}
|
||||
}
|
||||
|
||||
function onFsChange() {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
isFullscreen.value = !!currentFullscreenElement()
|
||||
emit('change', isFullscreen.value)
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('fullscreenchange', onFsChange))
|
||||
onBeforeUnmount(() => document.removeEventListener('fullscreenchange', onFsChange))
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', onFsChange)
|
||||
document.addEventListener('webkitfullscreenchange', onFsChange)
|
||||
// Shadow roots get their own fullscreenchange events that don't bubble
|
||||
// up to `document`. Without this, exiting via Esc inside the shadow
|
||||
// tree leaves our local ref stuck at `true` and the next tap of the
|
||||
// button tries to re-enter rather than exit.
|
||||
const sr = props.mapContainer?.getRootNode?.()
|
||||
if (sr instanceof ShadowRoot) {
|
||||
shadowRootRef = sr
|
||||
sr.addEventListener('fullscreenchange', onFsChange)
|
||||
sr.addEventListener('webkitfullscreenchange', onFsChange)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', onFsChange)
|
||||
document.removeEventListener('webkitfullscreenchange', onFsChange)
|
||||
if (shadowRootRef) {
|
||||
shadowRootRef.removeEventListener('fullscreenchange', onFsChange)
|
||||
shadowRootRef.removeEventListener('webkitfullscreenchange', onFsChange)
|
||||
shadowRootRef = null
|
||||
}
|
||||
// If the component is torn down while in pseudo-fullscreen, release
|
||||
// the host element so it doesn't stay fixed to the viewport.
|
||||
if (pseudoHostEl) exitPseudoFullscreen()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,21 @@
|
|||
import { ref, onMounted, onBeforeUnmount, computed, inject, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDoxaSearch } from '../../composables/useDoxaSearch.js'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
// iOS Safari auto-zooms the page whenever a text input with computed
|
||||
// font-size < 16px receives focus, and the "Done" keyboard button re-fires
|
||||
// the same zoom on blur. We override the Mapbox geocoder input to 16px in
|
||||
// the shadow root so the auto-zoom never triggers. 16px is the documented
|
||||
// threshold; setting it explicitly here also stops the page from getting
|
||||
// stuck zoomed-in on devices that disallow pinch-zoom recovery.
|
||||
useShadowStyles(
|
||||
'.mapboxgl-ctrl-geocoder--input,'
|
||||
+ '.mapboxgl-ctrl-geocoder input[type="text"],'
|
||||
+ '.mapboxgl-ctrl-geocoder input[type="search"]'
|
||||
+ '{font-size:16px !important;line-height:1.3;}',
|
||||
'geocoder-ios-zoom-fix'
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,20 @@
|
|||
MapToolbar > ZoomInButton :map="mapA" ...
|
||||
MapToolbar > ZoomInButton :map="mapB" ...
|
||||
|
||||
No props required — the slot contents own all state and map bindings.
|
||||
Props:
|
||||
has-geocoder (Boolean, default true) — whether a geocoder/search pill is
|
||||
rendered above the toolbar on mobile. When false, the mobile top offset
|
||||
collapses to the same 10px used on desktop (no wasted space above the
|
||||
first control button). Default preserves the legacy mobile layout for
|
||||
profiles that DO mount a geocoder.
|
||||
-->
|
||||
<script setup>
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
defineProps({
|
||||
hasGeocoder: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
useShadowStyles(`
|
||||
.map-toolbar {
|
||||
position: absolute;
|
||||
|
|
@ -29,16 +38,19 @@ useShadowStyles(`
|
|||
z-index: 10;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile: drop the toolbar well below the search bar so it doesn't sit
|
||||
flush against the search pill. Search = top:10, height ~36, bottom:46.
|
||||
top:80 leaves a comfortable ~34px gap. */
|
||||
/* Mobile (with geocoder): drop the toolbar well below the search bar so
|
||||
it doesn't sit flush against the search pill. Search = top:10,
|
||||
height ~36, bottom:46. top:80 leaves a comfortable ~34px gap. */
|
||||
.map-toolbar { top: 80px; gap: 6px; }
|
||||
/* Mobile (no geocoder): collapse the reserved gap — controls sit flush
|
||||
to the top of the map area with the same 10px inset used on desktop. */
|
||||
.map-toolbar.map-toolbar--no-geocoder { top: 10px; gap: 6px; }
|
||||
}
|
||||
`, 'map-toolbar')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-toolbar">
|
||||
<div class="map-toolbar" :class="{ 'map-toolbar--no-geocoder': !hasGeocoder }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,42 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/src/components/map-controls/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: map-controls
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
- src
|
||||
- components
|
||||
- map-controls
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons: []
|
||||
svg-candidate-score: 0
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.452778+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:39.830805+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- buttons-mapcontrolbutton-vue
|
||||
- vue-base-button
|
||||
- zoomin-zoomoutbutton-vue
|
||||
- vue-toggle-fullscreen
|
||||
- container-hamburgerbutton-vue
|
||||
- vue-file-role
|
||||
- toolbar-controls-file
|
||||
voice-memo-shape: false
|
||||
word-count: 214
|
||||
---
|
||||
|
||||
# template/src/components/map-controls/
|
||||
|
||||
> **Per-button toolbar controls.** Each file is a single, focused button or input that mounts inside the map's control area. Replaces the older monolithic `../MapControlsComponent.vue`.
|
||||
|
|
|
|||
|
|
@ -1,41 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/src/components/poster/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: poster
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
- src
|
||||
- components
|
||||
- poster
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons: []
|
||||
svg-candidate-score: 0
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.453429+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:40.231696+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- export-posterpreview-vue
|
||||
- posterdialog-vue-modal
|
||||
- composable-poster-slots
|
||||
- config-postersizes-js
|
||||
- drive-usemapposter-useposterlayout
|
||||
- dialog-preview
|
||||
voice-memo-shape: false
|
||||
word-count: 161
|
||||
---
|
||||
|
||||
# template/src/components/poster/
|
||||
|
||||
> **Poster export UI.** The dialog and preview surfaces that drive `useMapPoster` / `usePosterLayout`. Used by `research-map.vue` for printable posters.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* FooterSlot.vue — STUB. Poster-rendering slot for FooterSlot.
|
||||
* Wired into PosterPreview.vue. Real implementation pending — see
|
||||
* Map-Framework/intel/build-friction/2026-04-23-research-mfe-bootstrap.md
|
||||
* the build-friction notes
|
||||
* (Friction 13/14: Wave-3 poster agent ran out before finishing slots).
|
||||
*/
|
||||
defineProps({ spec: { type: Object, default: () => ({}) } })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* LegendSlot.vue — STUB. Poster-rendering slot for LegendSlot.
|
||||
* Wired into PosterPreview.vue. Real implementation pending — see
|
||||
* Map-Framework/intel/build-friction/2026-04-23-research-mfe-bootstrap.md
|
||||
* the build-friction notes
|
||||
* (Friction 13/14: Wave-3 poster agent ran out before finishing slots).
|
||||
*/
|
||||
defineProps({ spec: { type: Object, default: () => ({}) } })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* NorthArrowSlot.vue — STUB. Poster-rendering slot for NorthArrowSlot.
|
||||
* Wired into PosterPreview.vue. Real implementation pending — see
|
||||
* Map-Framework/intel/build-friction/2026-04-23-research-mfe-bootstrap.md
|
||||
* the build-friction notes
|
||||
* (Friction 13/14: Wave-3 poster agent ran out before finishing slots).
|
||||
*/
|
||||
defineProps({ spec: { type: Object, default: () => ({}) } })
|
||||
|
|
|
|||
|
|
@ -1,40 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/src/components/poster/slots/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: slots
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
- src
|
||||
- components
|
||||
- poster
|
||||
- slots
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons: []
|
||||
svg-candidate-score: 0
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.454025+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:40.528396+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- vue-poster-title
|
||||
- composable-poster-slots
|
||||
- composables-useposterlayout-js
|
||||
- component-fills-named
|
||||
voice-memo-shape: false
|
||||
word-count: 130
|
||||
---
|
||||
|
||||
# template/src/components/poster/slots/
|
||||
|
||||
> **Pluggable poster slots.** Each slot is a small Vue component that fills one named region (title, legend, footer, etc.) of the assembled poster.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* ScaleBarSlot.vue — STUB. Poster-rendering slot for ScaleBarSlot.
|
||||
* Wired into PosterPreview.vue. Real implementation pending — see
|
||||
* Map-Framework/intel/build-friction/2026-04-23-research-mfe-bootstrap.md
|
||||
* the build-friction notes
|
||||
* (Friction 13/14: Wave-3 poster agent ran out before finishing slots).
|
||||
*/
|
||||
defineProps({ spec: { type: Object, default: () => ({}) } })
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
// 3. 'network' — Nearest-neighbor shard/gem effect (denser visual)
|
||||
//
|
||||
// PORT NOTES:
|
||||
// - Algorithm code (Kruskal, nearest-neighbor) ported from FARFAST3-ETHNOGRAPHIC.
|
||||
// - Algorithm code (Kruskal, nearest-neighbor) ported from the upstream clustering project.
|
||||
// - Pinia/inject wiring pattern adopted from doxa-simple-map-component, but
|
||||
// adapted to the Map-Framework convention which uses provide('mapStore', …)
|
||||
// adapted to the upstream convention which uses provide('mapStore', …)
|
||||
// in ProfileLoader.vue (NOT a `stores` umbrella). We `inject('mapStore')`
|
||||
// synchronously in setup so multi-instance Pinia stays isolated.
|
||||
// - The composable never imports stores directly — keeps each <doxa-map>
|
||||
|
|
|
|||
|
|
@ -294,6 +294,308 @@ export function useMapFly(options = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
// Uniform legend-row camera constants. Single source of truth for how
|
||||
// tight a row-click zooms — keep these conservative so even sparse rows
|
||||
// (single pin, single country) don't slam the camera into street-level.
|
||||
const LEGEND_FIT_PADDING_PX = 80;
|
||||
const LEGEND_FIT_DURATION_MS = 800;
|
||||
const LEGEND_FIT_MAX_ZOOM = 5;
|
||||
const LEGEND_SINGLE_PIN_ZOOM = 5;
|
||||
|
||||
// Antimeridian-aware longitude span. For scopes that cross ±180° (Oceania,
|
||||
// east-Russia, Aleutians), naive min/max returns a span of ~360° because
|
||||
// points sit at -179 and +179. Re-project longitudes into [0, 360) and
|
||||
// recompute; pick the smaller span. Returns { minLng, maxLng } in the
|
||||
// representation that yields the tighter bbox. Caller must accept that
|
||||
// maxLng can exceed 180 — Mapbox's fitBounds accepts that and wraps.
|
||||
function _tightLngBounds(longitudes) {
|
||||
if (!longitudes.length) return null;
|
||||
let aMin = Infinity, aMax = -Infinity; // raw [-180, 180]
|
||||
let bMin = Infinity, bMax = -Infinity; // shifted [0, 360)
|
||||
for (const lng of longitudes) {
|
||||
if (!Number.isFinite(lng)) continue;
|
||||
if (lng < aMin) aMin = lng;
|
||||
if (lng > aMax) aMax = lng;
|
||||
const shifted = lng < 0 ? lng + 360 : lng;
|
||||
if (shifted < bMin) bMin = shifted;
|
||||
if (shifted > bMax) bMax = shifted;
|
||||
}
|
||||
if (!Number.isFinite(aMin)) return null;
|
||||
const spanA = aMax - aMin;
|
||||
const spanB = bMax - bMin;
|
||||
if (spanB < spanA) {
|
||||
// The cross-antimeridian representation is tighter. Convert back
|
||||
// to fitBounds-friendly form: keep minLng in [-180,180] and let
|
||||
// maxLng exceed 180 (Mapbox wraps it correctly).
|
||||
const minRaw = bMin > 180 ? bMin - 360 : bMin;
|
||||
return { minLng: minRaw, maxLng: minRaw + spanB };
|
||||
}
|
||||
return { minLng: aMin, maxLng: aMax };
|
||||
}
|
||||
|
||||
// Why: map.querySourceFeatures(sourceId, {filter}) is viewport-clipped —
|
||||
// pins outside the rendered camera are dropped, so legend rows for off-screen
|
||||
// regions silently no-op when the user is zoomed in. We evaluate the Mapbox
|
||||
// filter expression against the full source data instead, so fitBounds can
|
||||
// zoom OUT to a different region or IN to a tight cluster uniformly.
|
||||
function _resolveExpr(expr, props) {
|
||||
if (!Array.isArray(expr)) return expr;
|
||||
const op = expr[0];
|
||||
switch (op) {
|
||||
case 'get': return props?.[expr[1]];
|
||||
case 'literal': return expr[1];
|
||||
case 'has': return props != null && expr[1] in props;
|
||||
case '!has': return props == null || !(expr[1] in props);
|
||||
case '!': return !_resolveExpr(expr[1], props);
|
||||
case 'coalesce': {
|
||||
for (let i = 1; i < expr.length; i++) {
|
||||
const v = _resolveExpr(expr[i], props);
|
||||
if (v != null) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'to-number': {
|
||||
const v = _resolveExpr(expr[1], props);
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
if (expr.length > 2) {
|
||||
const fb = Number(_resolveExpr(expr[2], props));
|
||||
return Number.isFinite(fb) ? fb : 0;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
case 'to-string': {
|
||||
const v = _resolveExpr(expr[1], props);
|
||||
return v == null ? '' : String(v);
|
||||
}
|
||||
case 'to-boolean': return Boolean(_resolveExpr(expr[1], props));
|
||||
case 'slice': {
|
||||
const s = _resolveExpr(expr[1], props);
|
||||
const i = _resolveExpr(expr[2], props);
|
||||
const j = expr.length > 3 ? _resolveExpr(expr[3], props) : undefined;
|
||||
if (typeof s !== 'string') return '';
|
||||
return j != null ? s.slice(i, j) : s.slice(i);
|
||||
}
|
||||
default: return expr;
|
||||
}
|
||||
}
|
||||
function _matchesFilter(filter, props) {
|
||||
if (filter === true) return true;
|
||||
if (filter === false) return false;
|
||||
if (!Array.isArray(filter)) return Boolean(filter);
|
||||
const op = filter[0];
|
||||
if (op === 'all') {
|
||||
for (let i = 1; i < filter.length; i++) {
|
||||
if (!_matchesFilter(filter[i], props)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (op === 'any') {
|
||||
for (let i = 1; i < filter.length; i++) {
|
||||
if (_matchesFilter(filter[i], props)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (op === '!') return !_matchesFilter(filter[1], props);
|
||||
if (op === 'has') return props != null && filter[1] in props;
|
||||
if (op === '!has') return props == null || !(filter[1] in props);
|
||||
|
||||
if (op === '==' || op === '!=' || op === '>' || op === '>=' ||
|
||||
op === '<' || op === '<=') {
|
||||
// Legacy form: ['==', 'propName', value] (no ['get', ...])
|
||||
const left = (typeof filter[1] === 'string')
|
||||
? props?.[filter[1]]
|
||||
: _resolveExpr(filter[1], props);
|
||||
const right = _resolveExpr(filter[2], props);
|
||||
switch (op) {
|
||||
case '==': return left === right || (left == null && right == null);
|
||||
case '!=': return !(left === right || (left == null && right == null));
|
||||
case '>': return left > right;
|
||||
case '>=': return left >= right;
|
||||
case '<': return left < right;
|
||||
case '<=': return left <= right;
|
||||
}
|
||||
}
|
||||
if (op === 'in') {
|
||||
const v = _resolveExpr(filter[1], props);
|
||||
const haystack = _resolveExpr(filter[2], props);
|
||||
if (Array.isArray(haystack)) return haystack.includes(v);
|
||||
if (typeof haystack === 'string') return haystack.includes(v);
|
||||
return false;
|
||||
}
|
||||
if (op === 'match') {
|
||||
const input = _resolveExpr(filter[1], props);
|
||||
const lastIdx = filter.length - 1;
|
||||
for (let i = 2; i < lastIdx; i += 2) {
|
||||
const labelRaw = filter[i];
|
||||
const labels = Array.isArray(labelRaw) ? labelRaw : [labelRaw];
|
||||
if (labels.includes(input)) return Boolean(_resolveExpr(filter[i + 1], props));
|
||||
}
|
||||
return Boolean(_resolveExpr(filter[lastIdx], props));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _readSourceFeatures(map, sourceId) {
|
||||
const src = map.getSource(sourceId);
|
||||
if (!src) return [];
|
||||
const data = src._data ?? src.serialize?.()?.data;
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data?.features)) return data.features;
|
||||
if (data?.type === 'Feature') return [data];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* zoomToLegendRow — uniform "click a legend row, fit the scope it represents."
|
||||
*
|
||||
* Reads the full `language-families` source data via map.getSource()._data
|
||||
* and evaluates the Mapbox filter expression in JS, bypassing the viewport
|
||||
* clip that previously caused off-screen regions to silently no-op. fitBounds
|
||||
* is bidirectional — it zooms OUT to a wider region just as readily as it
|
||||
* zooms IN to a tight cluster, so Africa-row and North-India-cluster-row
|
||||
* feel like the same gesture from any starting camera.
|
||||
*
|
||||
* count == 0 → no-op (intentional — never blank-out the map)
|
||||
* count == 1 → flyTo single-pin coords @ LEGEND_SINGLE_PIN_ZOOM
|
||||
* count >= 2 → fitBounds with padding LEGEND_FIT_PADDING_PX
|
||||
*
|
||||
* @param {Object} row - STL legend node { id, label, filter, ... }
|
||||
* @param {Object} [opts]
|
||||
* @param {Array} [opts.matchExpr] - Override Mapbox expression for flat tabs
|
||||
* (prayer/engagement/adoption) where row.filter
|
||||
* is a placeholder against `_flat_filter`.
|
||||
* @param {string} [opts.sourceId='language-families'] - Pin source id to query
|
||||
*/
|
||||
function zoomToLegendRow(row, opts = {}) {
|
||||
const map = getMap();
|
||||
if (!map || !row) return;
|
||||
|
||||
// Region rows on the doxa-regions tab represent polygons, not pin clusters.
|
||||
// The pin-cluster path below would zoom to whatever sparse pins happen to
|
||||
// overlap the region's bbox — which for sparse regions like Oceania zooms
|
||||
// OUT to fit a few scattered pins. Fit the polygon directly instead, so
|
||||
// "click Oceania" zooms TO Oceania.
|
||||
if (opts.regionPolygonSource && map.getSource(opts.regionPolygonSource)) {
|
||||
const polyFeatures = _readSourceFeatures(map, opts.regionPolygonSource);
|
||||
const regionKey = row.id ?? row.label;
|
||||
const match = polyFeatures.find(f => {
|
||||
const p = f?.properties || {};
|
||||
return p.region === regionKey || p.name === regionKey || p.id === regionKey;
|
||||
});
|
||||
if (match) {
|
||||
const lngs = [];
|
||||
let pMinLat = Infinity, pMaxLat = -Infinity;
|
||||
const visit = (coords) => {
|
||||
for (const c of coords) {
|
||||
if (Array.isArray(c[0])) visit(c);
|
||||
else if (Number.isFinite(c[0]) && Number.isFinite(c[1])) {
|
||||
lngs.push(c[0]);
|
||||
if (c[1] < pMinLat) pMinLat = c[1];
|
||||
if (c[1] > pMaxLat) pMaxLat = c[1];
|
||||
}
|
||||
}
|
||||
};
|
||||
if (match.geometry?.coordinates) visit(match.geometry.coordinates);
|
||||
const lngBounds = _tightLngBounds(lngs);
|
||||
if (lngBounds && Number.isFinite(pMinLat)) {
|
||||
map.fitBounds([[lngBounds.minLng, pMinLat], [lngBounds.maxLng, pMaxLat]], {
|
||||
padding: LEGEND_FIT_PADDING_PX,
|
||||
maxZoom: LEGEND_FIT_MAX_ZOOM,
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
essential: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Polygon not found — fall through to pin-cluster path below.
|
||||
}
|
||||
|
||||
const sourceId = opts.sourceId ?? 'language-families';
|
||||
if (!map.getSource(sourceId)) return;
|
||||
|
||||
const filter = opts.matchExpr ?? row.filter;
|
||||
if (!filter) return;
|
||||
|
||||
const allFeatures = _readSourceFeatures(map, sourceId);
|
||||
if (!allFeatures.length) return;
|
||||
|
||||
const lngs = [];
|
||||
let minLat = Infinity, maxLat = -Infinity;
|
||||
let pointCount = 0;
|
||||
let firstLng = 0, firstLat = 0;
|
||||
const seen = new Set();
|
||||
for (const f of allFeatures) {
|
||||
const props = f?.properties;
|
||||
if (!_matchesFilter(filter, props)) continue;
|
||||
const id = props?.uniqueId;
|
||||
if (id != null) {
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
}
|
||||
const c = f.geometry?.coordinates;
|
||||
if (!Array.isArray(c) || c.length < 2) continue;
|
||||
const lng = c[0], lat = c[1];
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue;
|
||||
if (pointCount === 0) { firstLng = lng; firstLat = lat; }
|
||||
lngs.push(lng);
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
pointCount++;
|
||||
}
|
||||
if (pointCount === 0) return;
|
||||
|
||||
// Antimeridian-aware longitude bounds — prevents Oceania/east-Russia
|
||||
// rows from "zooming out to fit both wrap-around copies."
|
||||
const lngBounds = _tightLngBounds(lngs);
|
||||
const minLng = lngBounds ? lngBounds.minLng : 0;
|
||||
const maxLng = lngBounds ? lngBounds.maxLng : 0;
|
||||
|
||||
if (pointCount === 1) {
|
||||
map.flyTo({
|
||||
center: [firstLng, firstLat],
|
||||
zoom: LEGEND_SINGLE_PIN_ZOOM,
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
essential: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = [[minLng, minLat], [maxLng, maxLat]];
|
||||
try {
|
||||
// Why: when the legend row's scope spans a region wider than the
|
||||
// mobile viewport can render at its effective minZoom, fitBounds
|
||||
// animates toward a zoom < minZoom and Mapbox clamps mid-flight,
|
||||
// causing a visible snap/jolt. Probe the would-be fit zoom first
|
||||
// via cameraForBounds (no animation), and if it's tighter than
|
||||
// the camera's minZoom (with 0.3 slack to absorb float rounding),
|
||||
// flyTo the floor instead of fitBounds.
|
||||
const probe = typeof map.cameraForBounds === 'function'
|
||||
? map.cameraForBounds(bounds, { padding: LEGEND_FIT_PADDING_PX, maxZoom: LEGEND_FIT_MAX_ZOOM })
|
||||
: null;
|
||||
const minZoom = typeof map.getMinZoom === 'function' ? map.getMinZoom() : null;
|
||||
const wouldClamp = probe == null
|
||||
|| (typeof probe.zoom === 'number' && typeof minZoom === 'number'
|
||||
&& probe.zoom < minZoom - 0.3);
|
||||
if (wouldClamp && typeof minZoom === 'number') {
|
||||
const cx = (minLng + maxLng) / 2;
|
||||
const cy = (minLat + maxLat) / 2;
|
||||
map.flyTo({
|
||||
center: [cx, cy],
|
||||
zoom: minZoom,
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
essential: true
|
||||
});
|
||||
} else {
|
||||
map.fitBounds(bounds, {
|
||||
padding: LEGEND_FIT_PADDING_PX,
|
||||
maxZoom: LEGEND_FIT_MAX_ZOOM,
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
essential: true
|
||||
});
|
||||
}
|
||||
} catch (_) { /* fitBounds throws on degenerate bounds */ }
|
||||
}
|
||||
|
||||
return {
|
||||
// Core navigation
|
||||
getOffsetCenter,
|
||||
|
|
@ -303,6 +605,7 @@ export function useMapFly(options = {}) {
|
|||
flyToCoords,
|
||||
fitBounds,
|
||||
resetView,
|
||||
zoomToLegendRow,
|
||||
|
||||
// Utilities
|
||||
calculateRegionCenterFromPolygon
|
||||
|
|
|
|||
|
|
@ -529,8 +529,25 @@ export function useMapLayers(options = {}) {
|
|||
// on water or WiFi signal waves radiating outward.
|
||||
//
|
||||
// Pin radii: z0=3, z2=3.5, z4=4, z5=5, z6=6.5, z7=8, z8=10, z10=14, z12=18, z14=22
|
||||
//
|
||||
// Style-swap safety:
|
||||
// map.setStyle() removes all custom layers + sources. The host profile
|
||||
// already owns the post-style.load reconciliation path: it re-adds the
|
||||
// pin source via addLanguageFamilyLayer, then calls startPrayerGlow()
|
||||
// again. We DO NOT register our own style.load handler here — having
|
||||
// two listeners fire on the same style.load creates a remove-then-add
|
||||
// race against the host's reconciliation, which is the visible
|
||||
// "flash" on theme toggle. Single source of truth = host's
|
||||
// map.once('style.load') only. Our job is just to make sure that
|
||||
// when startPrayerGlow() is called repeatedly we do NOT churn layers
|
||||
// that are already correctly attached.
|
||||
let _glowRafId = null;
|
||||
let _glowActive = false;
|
||||
// Monotonic counter — incremented each _scheduleGlowTick() invocation.
|
||||
// Every tick closure remembers the epoch it was created under and
|
||||
// self-terminates if a newer scheduler has incremented it. Belt to the
|
||||
// cancelAnimationFrame suspenders inside _scheduleGlowTick().
|
||||
let _glowEpoch = 0;
|
||||
const GLOW_BASE = 'prayer-glow-base';
|
||||
const RING_COUNT = 4;
|
||||
const _RING_IDS = [];
|
||||
|
|
@ -568,41 +585,56 @@ export function useMapLayers(options = {}) {
|
|||
const prayerFilter = ['>', ['get', 'peoplePraying'], 0];
|
||||
const color = _glowColorExpr();
|
||||
|
||||
if (!map.getLayer(GLOW_BASE)) {
|
||||
// True idempotency: if ALL 5 glow layers already exist, do nothing.
|
||||
// The previous remove-then-re-add path reset ring stroke-opacity
|
||||
// and circle-radius to their addLayer defaults on every call,
|
||||
// producing a visible flash on the next paint until the rAF tick
|
||||
// wrote them back. Repeated calls to startPrayerGlow() (e.g. from
|
||||
// tab switches, theme toggles, or a host calling it after style
|
||||
// re-load) now leave already-attached layers alone — only the rAF
|
||||
// baseline is reset by _scheduleGlowTick().
|
||||
const allPresent = _GLOW_IDS.every(id => map.getLayer(id));
|
||||
if (allPresent) return;
|
||||
|
||||
// Partial state: some layers exist, some don't. Tear down what's
|
||||
// there so the re-add below produces a clean, consistent stack.
|
||||
for (const id of _GLOW_IDS) {
|
||||
if (map.getLayer(id)) {
|
||||
try { map.removeLayer(id); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
map.addLayer({
|
||||
id: GLOW_BASE,
|
||||
type: 'circle',
|
||||
source: 'language-families',
|
||||
filter: prayerFilter,
|
||||
paint: {
|
||||
'circle-radius': _scaledRadius(_BASE_R, 1.0),
|
||||
'circle-color': color,
|
||||
'circle-blur': 0.3,
|
||||
'circle-opacity': 0.45,
|
||||
'circle-stroke-width': 0,
|
||||
}
|
||||
}, 'language-family-pins');
|
||||
|
||||
for (const ringId of _RING_IDS) {
|
||||
map.addLayer({
|
||||
id: GLOW_BASE,
|
||||
id: ringId,
|
||||
type: 'circle',
|
||||
source: 'language-families',
|
||||
filter: prayerFilter,
|
||||
paint: {
|
||||
'circle-radius': _scaledRadius(_BASE_R, 1.0),
|
||||
'circle-color': color,
|
||||
'circle-blur': 0.3,
|
||||
'circle-opacity': 0.45,
|
||||
'circle-stroke-width': 0,
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-blur': 0,
|
||||
'circle-opacity': 1,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': color,
|
||||
'circle-stroke-opacity': 0,
|
||||
}
|
||||
}, 'language-family-pins');
|
||||
}
|
||||
|
||||
for (const ringId of _RING_IDS) {
|
||||
if (!map.getLayer(ringId)) {
|
||||
map.addLayer({
|
||||
id: ringId,
|
||||
type: 'circle',
|
||||
source: 'language-families',
|
||||
filter: prayerFilter,
|
||||
paint: {
|
||||
'circle-radius': _scaledRadius(_BASE_R, 1.0),
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-blur': 0,
|
||||
'circle-opacity': 1,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': color,
|
||||
'circle-stroke-opacity': 0,
|
||||
}
|
||||
}, 'language-family-pins');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removePrayerGlowLayers() {
|
||||
|
|
@ -629,7 +661,21 @@ export function useMapLayers(options = {}) {
|
|||
const map = getMap();
|
||||
if (!map) return;
|
||||
addPrayerGlowLayers();
|
||||
if (_glowActive) return;
|
||||
|
||||
// Cancel any pending rAF before scheduling a new one. Single source
|
||||
// of truth for the animation timer ID — guarantees exactly one
|
||||
// tick loop is ever running, even when the host calls
|
||||
// startPrayerGlow() multiple times (tab switch, theme toggle,
|
||||
// initial mount, etc.). No internal style.load listener is
|
||||
// registered: the host profile's own map.once('style.load') is
|
||||
// the only place that re-adds the pin source after a setStyle and
|
||||
// re-invokes startPrayerGlow(). Adding a second listener here
|
||||
// produced a paint-property race against the host's re-add path,
|
||||
// visible as a periodic brightness flash on every prayer dot.
|
||||
if (_glowRafId) {
|
||||
cancelAnimationFrame(_glowRafId);
|
||||
_glowRafId = null;
|
||||
}
|
||||
_glowActive = true;
|
||||
|
||||
if (map.getLayer('language-family-pins')) {
|
||||
|
|
@ -643,12 +689,40 @@ export function useMapLayers(options = {}) {
|
|||
} catch (_) {}
|
||||
}
|
||||
|
||||
_scheduleGlowTick();
|
||||
}
|
||||
|
||||
// Internal helper: spins up a fresh rAF tick with a fresh `t0` baseline.
|
||||
//
|
||||
// Concurrency contract:
|
||||
// Each call cancels any in-flight rAF before scheduling a new one
|
||||
// and bumps an epoch token; the tick closure self-terminates if a
|
||||
// newer scheduler has superseded it. Belt-and-suspenders against
|
||||
// a stray frame that was enqueued before the cancel landed.
|
||||
function _scheduleGlowTick() {
|
||||
// (1) Hard cancel any pending rAF — single source of truth for the
|
||||
// timer ID. Load-bearing for the duplicate-tick-leak fix.
|
||||
if (_glowRafId) {
|
||||
cancelAnimationFrame(_glowRafId);
|
||||
_glowRafId = null;
|
||||
}
|
||||
// (2) Epoch stamp — older closures still referencing a stale
|
||||
// epoch will exit on their next frame instead of writing
|
||||
// paint properties.
|
||||
const myEpoch = ++_glowEpoch;
|
||||
let t0 = performance.now();
|
||||
function tick(now) {
|
||||
if (!_glowActive) return;
|
||||
_glowRafId = requestAnimationFrame(tick);
|
||||
if (myEpoch !== _glowEpoch) return; // superseded by newer scheduler
|
||||
const m = getMap();
|
||||
if (!m || !_GLOW_IDS.some(id => m.getLayer(id))) { _glowActive = false; return; }
|
||||
if (!m || !_GLOW_IDS.some(id => m.getLayer(id))) {
|
||||
// Layers gone (mid style swap). Stop scheduling new frames;
|
||||
// _glowActive stays true so the host's post-style.load
|
||||
// call to startPrayerGlow() will restart this tick chain
|
||||
// once the layers come back.
|
||||
_glowRafId = null;
|
||||
return;
|
||||
}
|
||||
const elapsed = (now - t0) / 1000;
|
||||
|
||||
for (let i = 0; i < RING_COUNT; i++) {
|
||||
|
|
@ -668,6 +742,9 @@ export function useMapLayers(options = {}) {
|
|||
m.setPaintProperty(ringId, 'circle-blur', 0);
|
||||
} catch (_) {}
|
||||
}
|
||||
// Schedule next frame LAST, after work, so _glowRafId always
|
||||
// reflects the most recently scheduled frame for this chain.
|
||||
_glowRafId = requestAnimationFrame(tick);
|
||||
}
|
||||
_glowRafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
|
@ -675,6 +752,8 @@ export function useMapLayers(options = {}) {
|
|||
function stopPrayerGlow() {
|
||||
_glowActive = false;
|
||||
if (_glowRafId) { cancelAnimationFrame(_glowRafId); _glowRafId = null; }
|
||||
// No persistent style.load listener to detach — the host profile
|
||||
// owns the post-style.load reconciliation path.
|
||||
try {
|
||||
removePrayerGlowLayers();
|
||||
const map = getMap();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* usePplrInstance.js — per-map "instance store" mediator.
|
||||
*
|
||||
* Ported verbatim from /Map-Framework/05-apps/PPLR-MAPS/PPLR-validate-data-mfe/src/composables/usePplrInstance.js
|
||||
* 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Central enum + lookup for all per-strategy modules in this folder.
|
||||
* Each strategy lives in its own file (one strategy = one file rule, per
|
||||
* Map-Framework refactor ideation3.md § R2).
|
||||
* upstream refactor ideation3.md § R2).
|
||||
*
|
||||
* Add a new strategy:
|
||||
* 1. Create <name>.js next to this file (export default { ... }).
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "لديه تبني"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "السكان"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "دورة الصلاة الحالية",
|
||||
"lap": "الدورة",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " من "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "Hat Adoption"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "Bevölkerung"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "AKTUELLE GEBETSRUNDE",
|
||||
"lap": "Runde",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " von "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"hasAdoption": "Has Adoption"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "Population"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "CURRENT PRAYER LAP",
|
||||
"lap": "Lap",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " of "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "Tiene adopción"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "Población"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "VUELTA DE ORACIÓN ACTUAL",
|
||||
"lap": "Vuelta",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " de "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "Adoption en cours"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "Population"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "TOUR DE PRIÈRE ACTUEL",
|
||||
"lap": "Tour",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " sur "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "गोद लिया गया"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "जनसंख्या"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "वर्तमान प्रार्थना चक्र",
|
||||
"lap": "चक्र",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " में से "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "Ha adozione"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "Popolazione"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "GIRO DI PREGHIERA ATTUALE",
|
||||
"lap": "Giro",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " di "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "Tem adoção"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "População"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "VOLTA DE ORAÇÃO ATUAL",
|
||||
"lap": "Volta",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " de "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "Are adopție"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "Populație"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "TURUL DE RUGĂCIUNE CURENT",
|
||||
"lap": "Tur",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " din "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "Есть усыновление"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "Население"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "ТЕКУЩИЙ КРУГ МОЛИТВЫ",
|
||||
"lap": "Круг",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " из "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"hasAdoption": "已有认领"
|
||||
},
|
||||
"columns": {
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"population": "人口"
|
||||
},
|
||||
"footer": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"header": "当前祷告轮次",
|
||||
"lap": "第",
|
||||
"upgs": "UPGs",
|
||||
"upgs": "UUPGs",
|
||||
"separator": " / "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/src/stores/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: stores
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
- src
|
||||
- stores
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons: []
|
||||
svg-candidate-score: 0
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.450553+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:38.335493+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- mapstore-js
|
||||
- profileloader-vue-import
|
||||
- doxa-map-customelements
|
||||
- activemapid-uistore
|
||||
- legendstate-prayerfilter-datastore
|
||||
- mapbox-instances-selectedregion
|
||||
- instance-pinia
|
||||
voice-memo-shape: false
|
||||
word-count: 198
|
||||
---
|
||||
|
||||
# template/src/stores/
|
||||
|
||||
> **Per-instance Pinia stores.** Three stores, one responsibility each. Created fresh per `<doxa-map>` element by `entry.js`. Exposed to descendants via `provide()` from `ProfileLoader.vue` — never import directly elsewhere (LL-010, LL-023).
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* - MSTClusteringUtil.js (Kruskal MST)
|
||||
* - NetworkClusteringUtil.js (nearest-neighbor)
|
||||
*
|
||||
* Convex hull is extracted from FARFAST3-ETHNOGRAPHIC/PolygonClusteringFeature.js.
|
||||
* Convex hull is extracted from the upstream PolygonClusteringFeature module.
|
||||
* Centroid + distance helpers are extracted to avoid duplication across the
|
||||
* clustering modules.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
* - Better visual clarity
|
||||
* - No maxDistance parameter needed (connects everything)
|
||||
*
|
||||
* Ported verbatim from FARFAST3-ETHNOGRAPHIC/MSTClusteringUtil.js (~250 lines)
|
||||
* to the Map-Framework template. No external deps beyond ES2020.
|
||||
* Ported verbatim from the upstream MSTClusteringUtil module (~250 lines)
|
||||
* to the the upstream template. No external deps beyond ES2020.
|
||||
*
|
||||
* @module utils/MSTClusteringUtil
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
* "embedded" in its group). Use MSTClusteringUtil when you want a clean
|
||||
* tree with no redundant edges.
|
||||
*
|
||||
* Ported verbatim from FARFAST3-ETHNOGRAPHIC/NetworkClusteringUtil.js (~393 lines)
|
||||
* to the Map-Framework template. No external deps beyond ES2020.
|
||||
* Ported verbatim from the upstream NetworkClusteringUtil module (~393 lines)
|
||||
* to the the upstream template. No external deps beyond ES2020.
|
||||
*
|
||||
* @module utils/NetworkClusteringUtil
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,39 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/1040-maps/src/utils/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: utils
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
- 1040-maps
|
||||
- src
|
||||
- utils
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons: []
|
||||
svg-candidate-score: 0
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.451195+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:38.753498+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- geojson-helpers-clusterhelpers
|
||||
- javascript-helpers-vue
|
||||
- window-mapappapiurl-viteapibaseurl
|
||||
- js-resolves-api
|
||||
- networkclusteringutil-js
|
||||
voice-memo-shape: false
|
||||
word-count: 187
|
||||
---
|
||||
|
||||
# template/src/utils/
|
||||
|
||||
> **Pure JavaScript helpers.** No Vue, no Pinia, no Mapbox imports. Anything reactive is a composable; anything stateful is a store. See [`/docs/utils/`](../../../docs/utils/) and [`/docs/geo-math/`](../../../docs/geo-math/) for the full catalog.
|
||||
|
|
|
|||
|
|
@ -1,38 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/micro-frontends/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: micro-frontends
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- micro-frontends
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons:
|
||||
- keyword:blueprint
|
||||
svg-candidate-score: 1
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.447660+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:36.273781+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- vite-config
|
||||
- vue-micro-frontend
|
||||
- micro-frontend-imports
|
||||
- template-composables-components
|
||||
- npm-run-build
|
||||
- iife
|
||||
voice-memo-shape: false
|
||||
word-count: 236
|
||||
---
|
||||
|
||||
# micro-frontends/
|
||||
|
||||
Full-application-sized custom elements (Vue + Vite) that bundle into single IIFE files and register as custom elements. Not to be confused with `web-components/` (small/single-purpose things).
|
||||
|
|
@ -85,4 +50,3 @@ Vite traces imports + tree-shakes — each micro-frontend bundles only what it a
|
|||
|
||||
- `web-components/` — smaller, single-purpose custom elements.
|
||||
- `doxa-life/public/js/` — where public bundles are served from.
|
||||
- Design notes: `/home/qwerty/Workspace/port-kingdom/FMWK-Vite-CDN/QA-Map-MFE-CDN-Deployment.md`.
|
||||
|
|
@ -1,37 +1,3 @@
|
|||
---
|
||||
id: far-fast-tools/FARFAST1-HOP-POP-PLOT/DOXA/doxa-website-nuxt/doxa-life/embeddables/web-components/README
|
||||
audience: trinitarian-believers
|
||||
audience-strict-66-book: true
|
||||
framework: farfast-1
|
||||
kingdom-kernel: false
|
||||
parent: web-components
|
||||
path-tags:
|
||||
- far-fast-tools
|
||||
- FARFAST1-HOP-POP-PLOT
|
||||
- DOXA
|
||||
- doxa-website-nuxt
|
||||
- doxa-life
|
||||
- embeddables
|
||||
- web-components
|
||||
size-class: short
|
||||
svg-candidate: false
|
||||
svg-candidate-reasons:
|
||||
- keyword:blueprint
|
||||
svg-candidate-score: 1
|
||||
svg-candidate-tagged-at: '2026-05-09T17:40:05.448388+00:00'
|
||||
svg-candidate-version: '0.2'
|
||||
tagged-at: '2026-05-09T05:49:36.736659+00:00'
|
||||
tagged-by: lk10x-tagger-v0.1.0
|
||||
tags:
|
||||
- micro-frontend-embedded
|
||||
- custom-elements-vue
|
||||
- distinction-granularity-size
|
||||
- js-destination-distinction
|
||||
- prefer-keeping-web
|
||||
voice-memo-shape: false
|
||||
word-count: 235
|
||||
---
|
||||
|
||||
# web-components/
|
||||
|
||||
Small, single-purpose custom elements (Vue + Vite) — smaller in scope than anything in `micro-frontends/`.
|
||||
|
|
|
|||
|
|
@ -125,6 +125,13 @@ export default defineNuxtConfig({
|
|||
{ rel: 'apple-touch-icon', href: '/favicon/cropped-Favicon-light-doxa-01-180x180.png' }
|
||||
],
|
||||
meta: [
|
||||
// Mobile viewport. `viewport-fit=cover` lets the map canvas extend
|
||||
// behind iOS notch/home-indicator safe areas. We intentionally do
|
||||
// NOT set `user-scalable=no` or `maximum-scale=1` — those break
|
||||
// pinch-zoom recovery if the page ever does get zoomed. The real
|
||||
// fix for "tapping the search input zooms the page" is keeping the
|
||||
// input's font-size >= 16px (handled in the geocoder shadow CSS).
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' },
|
||||
{ name: 'msapplication-TileImage', content: '/favicon/cropped-Favicon-light-doxa-01-270x270.png' }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,7 @@
|
|||
<link rel="icon" href="/favicon.ico">
|
||||
|
||||
<!--
|
||||
Static showcase of port-kingdom/Map-Framework/05-apps/PPLR-MAPS/PPLR-validate-data-mfe.
|
||||
Static showcase of the upstream PPLR-validate-data project.
|
||||
|
||||
Mapbox token: fetched at runtime from /api/maps/token (Nuxt server endpoint).
|
||||
The endpoint returns either a public key (pk.*) directly OR mints a
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
* caches it server-side, returns the TK
|
||||
*
|
||||
* Pattern ported from `dt-geo-steward/geo-steward.php :: get_temp_key()`
|
||||
* (DT WordPress plugin in Map-Framework/05-apps/dt-geo-steward).
|
||||
*
|
||||
* Why a server endpoint instead of `runtimeConfig.public.mapboxToken`?
|
||||
* 1. Static HTML pages in /public/ can't read runtimeConfig — they need
|
||||
|
|
|
|||
Loading…
Reference in a new issue