Search is FUZZY again, Fuse-parity: typo tolerance restored on every surface
Some checks are pending
build-gate / build (push) Waiting to run

DX-life's search bakes in real Fuse.js { threshold: 0.4, ignoreLocation,
minMatchCharLength: 3 } (UupgsList.vue), which tolerates typos -- an
extra trailing letter ('Bhutann') or a substitution ('Indoa') still
finds the country. Two things were lost in migration:

1. The vendored @map fuzzy.js 'fuzzy' fallback was a strict SUBSEQUENCE
   matcher -- an EXTRA needle char always fails a subsequence, so the
   exact typo class Fuse tolerates was the class it rejected. Replaced
   with the standard k-errors approximate-substring DP (Sellers):
   match iff errors/pattern-length <= threshold, same score contract
   as Fuse with ignoreLocation. Highlight indices = best window.

2. The dashboard's makeSearchMatcher was a plain .includes() -- fuzzy
   never reached the topbar search at all. It now delegates to the
   shared fuzzyMatches() with the exact DX-life options, so the
   dashboard and the grid agree on one matcher.

Measured: dashboard 'Bhutann' 0 -> 13, 'Indoa' 0 -> 1,319, 'zzqqx' 0,
clear 2,107; grid standalone 'Bhutann' -> 13 Bhutan cards, highlights
render. Exact terms now also admit <=k-error neighbours ('Bhutan' 13
-> 132) -- the same widening real Fuse applies in DX-life.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 23:08:30 +00:00
parent e957cbe85f
commit ff0a996778
18 changed files with 139 additions and 107 deletions

View file

@ -124,7 +124,7 @@ A zero-dependency Fuse.js-shaped matcher (so no npm dep is added to the shared
```js
const fuse = new Fuse(list, {
keys: ['name', 'country_code.label', 'religion.label'], // dotted paths
threshold: 0, // 0 = exact substring only; >0 = fuzzy subsequence
threshold: 0, // 0 = exact substring only; >0 = fuzzy (k-errors edit distance, Fuse-parity)
minMatchCharLength: 3, // shortest run counted as a match
})
fuse.search(term) // -> [{ item, matches: [{ key, indices: [[start,end], …] }] }]

View file

@ -13,7 +13,7 @@
* with { keys, threshold, includeMatches, ignoreLocation, minMatchCharLength }. We reproduce
* exactly the parts of that contract the grid relies on:
* - keys: array of dotted paths ('name', 'country_code.label', …) read from each item
* - threshold: 0 = exact substring only; >0 = fuzzy (subsequence) allowed
* - threshold: 0 = exact substring only; >0 = fuzzy (k-errors edit distance, Fuse-parity)
* - minMatchCharLength: shortest run counted as a match
* - .search(term) -> [{ item, matches:[{ key, indices:[[start,end], …] }] }]
* The `matches[].indices` drive the <span class="search-highlight"> wrapping in the grid,
@ -43,29 +43,57 @@ function exactIndices(haystack, needle) {
return out
}
// Fuzzy (subsequence) match: every char of `needle` appears in order in `haystack`.
// Returns the runs of consecutive matched positions, or null if not all chars matched.
function fuzzyIndices(haystack, needle) {
// ── Fuzzy match, FUSE-PARITY (2026-09-23) ────────────────────────────────────────────────
// This used to be a strict SUBSEQUENCE matcher ("every char of needle appears in order"), and
// that is NOT what Fuse.js does. The difference bit in production: DX-life's real Fuse
// (threshold 0.4, ignoreLocation) tolerates TYPOS -- an extra trailing letter ("Bhutann"),
// a substituted one ("Indoa") -- because bitap scores k errors against the pattern length.
// A subsequence can only ever DROP haystack chars; an EXTRA needle char always fails it.
// Measured on the live dashboard: 'Bhutan' -> 13 rows, 'Bhutann' -> 0. The Driver's test.
//
// So this is the standard approximate-substring DP (Sellers/Ukkonen "k-errors" matching):
// best edit distance of the needle against ANY window of the haystack (free start, free end),
// matching iff errors/needle.length <= threshold -- the same score contract Fuse applies with
// ignoreLocation:true. Highlight indices are the best-scoring window's char span.
function approxIndices(haystack, needle, threshold) {
const h = haystack.toLowerCase()
const n = needle.toLowerCase()
const runs = []
let hi = 0
let runStart = -1
let runEnd = -1
for (let ni = 0; ni < n.length; ni++) {
const ch = n[ni]
let found = -1
for (let k = hi; k < h.length; k++) {
if (h[k] === ch) { found = k; break }
const m = n.length
if (!m || !h.length) return null
const maxErr = Math.floor(threshold * m)
if (maxErr <= 0) return null
// dp[j] = min errors matching the whole prefix n[0..i] ending at text position j.
// Row 0 is all zeros: the match may START anywhere (that is the "search" variant).
let prev = new Array(h.length + 1).fill(0)
let cur = new Array(h.length + 1)
for (let i = 1; i <= m; i++) {
cur[0] = i
for (let j = 1; j <= h.length; j++) {
const sub = prev[j - 1] + (n[i - 1] === h[j - 1] ? 0 : 1)
cur[j] = Math.min(sub, prev[j] + 1, cur[j - 1] + 1)
}
if (found === -1) return null
if (runStart === -1) { runStart = runEnd = found }
else if (found === runEnd + 1) { runEnd = found }
else { runs.push([runStart, runEnd]); runStart = runEnd = found }
hi = found + 1
;[prev, cur] = [cur, prev]
}
if (runStart !== -1) runs.push([runStart, runEnd])
return runs
let best = Infinity, bestEnd = -1
for (let j = 1; j <= h.length; j++) if (prev[j] < best) { best = prev[j]; bestEnd = j }
if (best > maxErr) return null
const start = Math.max(0, bestEnd - m)
return [[start, bestEnd - 1]]
}
/**
* Low-level predicate shared with the dashboard's search matcher (useDashboardShell.js):
* does `text` match `term` under the DX-life Fuse contract (exact substring first, then
* k-errors fuzzy when term.length >= minMatchCharLength)? ONE matcher, every search surface.
*/
export function fuzzyMatches(text, term, { threshold = 0.4, minMatchCharLength = 3 } = {}) {
const raw = String(text ?? '')
const q = String(term ?? '').trim()
if (!q) return true
if (!raw) return false
if (raw.toLowerCase().includes(q.toLowerCase())) return true
if (threshold === 0 || q.length < minMatchCharLength) return false
return approxIndices(raw, q, threshold) !== null
}
export default class Fuse {
@ -89,12 +117,8 @@ export default class Fuse {
if (typeof raw !== 'string' || !raw) continue
let indices = exactIndices(raw, q)
if (indices.length === 0 && !exactOnly) {
const fz = fuzzyIndices(raw, q)
if (fz) {
// keep only runs at least minMatchCharLength long (mirrors Fuse's minMatch)
const kept = fz.filter(([s, e]) => e - s + 1 >= Math.min(this.minMatchCharLength, q.length))
if (kept.length) indices = kept
}
const fz = approxIndices(raw, q, this.threshold)
if (fz) indices = fz
}
if (indices.length) matches.push({ key, indices })
}

View file

@ -23,6 +23,7 @@
* responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync.
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
*/
import { fuzzyMatches } from '@map/components/search/fuzzy.js'
import { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from './useSelectionBus.js'
import { useDashboardData } from './useDashboardData.js'
@ -59,12 +60,19 @@ export function buildSearchAccessors(config) {
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
*/
export function makeSearchMatcher(bus, accessors) {
// ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must
// still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with
// { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
// search tolerates typos. This matcher was a plain .includes() -- the one search surface that
// silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
// It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the
// grid's Fuse uses -- with the same DX-life options, so every search surface agrees.
return (row) => {
const needle = (bus?.searchTerm?.value || '').trim().toLowerCase()
const needle = (bus?.searchTerm?.value || '').trim()
if (!needle) return true
for (const acc of accessors) {
const v = acc(row)
if (v != null && String(v).toLowerCase().includes(needle)) return true
if (v != null && fuzzyMatches(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true
}
return false
}

File diff suppressed because one or more lines are too long

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-/ECcR7BrwpU7nnG+V8aNtA5yaAZizs4UWp/vjHP+DPa+P4xM6fOS+ki3hXJzgYVR" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-/ECcR7BrwpU7nnG+V8aNtA5yaAZizs4UWp/vjHP+DPa+P4xM6fOS+ki3hXJzgYVR" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -37,7 +37,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../big-picture.js" integrity="sha384-/ECcR7BrwpU7nnG+V8aNtA5yaAZizs4UWp/vjHP+DPa+P4xM6fOS+ki3hXJzgYVR" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-0E0JBGHpF+88UZ8VZfwLlu051GXQ3vz7kF7cGw7i8mKaSiiat3B0OJIzRa7TZ2pl" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-OIYFIlS3bWKVmWI+sw8CvqBayOJZih1OakmAYe4nKxJBCH76bgOLPQ2vfWrVmg4b" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-0E0JBGHpF+88UZ8VZfwLlu051GXQ3vz7kF7cGw7i8mKaSiiat3B0OJIzRa7TZ2pl" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-OIYFIlS3bWKVmWI+sw8CvqBayOJZih1OakmAYe4nKxJBCH76bgOLPQ2vfWrVmg4b" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -35,7 +35,7 @@
"bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js",
"output": "doxa-maps/big-picture/big-picture.js",
"integrity": "sha384-/ECcR7BrwpU7nnG+V8aNtA5yaAZizs4UWp/vjHP+DPa+P4xM6fOS+ki3hXJzgYVR",
"integrity": "sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4",
"elements": [
"bp-big-picture",
"bp-research-header",
@ -57,7 +57,7 @@
"scriptUrl": "./big-picture/big-picture.js",
"embed": {
"iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-/ECcR7BrwpU7nnG+V8aNtA5yaAZizs4UWp/vjHP+DPa+P4xM6fOS+ki3hXJzgYVR\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-MG/HIBfHvBjodOyOgVlzhgK5sZhEg2BI4DGPBc7yUmjGM9CDWI5JMvQh9nHRbJz4\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
},
"instances": [
{
@ -193,7 +193,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-0E0JBGHpF+88UZ8VZfwLlu051GXQ3vz7kF7cGw7i8mKaSiiat3B0OJIzRa7TZ2pl",
"integrity": "sha384-OIYFIlS3bWKVmWI+sw8CvqBayOJZih1OakmAYe4nKxJBCH76bgOLPQ2vfWrVmg4b",
"elements": [
"dashboard-plus-grid"
],
@ -213,7 +213,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-0E0JBGHpF+88UZ8VZfwLlu051GXQ3vz7kF7cGw7i8mKaSiiat3B0OJIzRa7TZ2pl\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-OIYFIlS3bWKVmWI+sw8CvqBayOJZih1OakmAYe4nKxJBCH76bgOLPQ2vfWrVmg4b\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -680,7 +680,7 @@
"bundle": "people-groups-grid",
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"output": "doxa-maps/people-groups-grid/people-groups-grid.js",
"integrity": "sha384-MSfrQ/WC0udyYniq0K0NWa+t3n/u9Cs5fVYEq41mfhQ8Qw5RAaeiVAXcsuMN5Dfk",
"integrity": "sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq",
"elements": [
"people-groups-grid"
],
@ -696,7 +696,7 @@
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"embed": {
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-MSfrQ/WC0udyYniq0K0NWa+t3n/u9Cs5fVYEq41mfhQ8Qw5RAaeiVAXcsuMN5Dfk\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
},
"instances": [
{
@ -878,7 +878,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "doxa-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-A03JwZapZiUbdJqog+qf6vtED5LzT8vA+gF7bYD6dhAtc1dvehkBGqEDygWjnKsY",
"integrity": "sha384-PUwk1rCbKvkDYH+C6ICyXS05vv5l0iAzoroiUY8uodO8Kgi02va7ENxSTQ6PzlSo",
"elements": [
"research-dashboard-map"
],
@ -894,7 +894,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-A03JwZapZiUbdJqog+qf6vtED5LzT8vA+gF7bYD6dhAtc1dvehkBGqEDygWjnKsY\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-PUwk1rCbKvkDYH+C6ICyXS05vv5l0iAzoroiUY8uodO8Kgi02va7ENxSTQ6PzlSo\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-MSfrQ/WC0udyYniq0K0NWa+t3n/u9Cs5fVYEq41mfhQ8Qw5RAaeiVAXcsuMN5Dfk" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../people-groups-grid.js" integrity="sha384-MSfrQ/WC0udyYniq0K0NWa+t3n/u9Cs5fVYEq41mfhQ8Qw5RAaeiVAXcsuMN5Dfk" crossorigin="anonymous"></script>
<script src="../people-groups-grid.js" integrity="sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-MSfrQ/WC0udyYniq0K0NWa+t3n/u9Cs5fVYEq41mfhQ8Qw5RAaeiVAXcsuMN5Dfk" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-SKWGRiLzac7kgXqwKmZ6zztgnwonaWQuelRXos+pToKjq9s7H1DgpKb/t+4zVsIq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-A03JwZapZiUbdJqog+qf6vtED5LzT8vA+gF7bYD6dhAtc1dvehkBGqEDygWjnKsY" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-PUwk1rCbKvkDYH+C6ICyXS05vv5l0iAzoroiUY8uodO8Kgi02va7ENxSTQ6PzlSo" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: