Merge main (studio landing 4) into packet/vertical
Landing 4 carries the nine messages keys this branch's packet and poster
funnel call (4be260a0), so the eleven catalogue conflicts resolve to MAIN'S
side in full: measured first, not assumed — my side added exactly those nine
keys to each of the eleven locales, changed no existing key, and all nine are
byte-identical on main. Whole-file take-theirs therefore loses nothing, which
is the only condition under which taking a whole shared catalogue is safe.
The six public/ conflicts are build artifacts and are NOT hand-merged: they
take main's side here and are regenerated from the merged source in the next
commit. A hand-merged bundle is what produced three consecutive SRI
mismatches, where leaf HTML pinned a hash for a bundle that no longer existed
and the browser refused the script — a blank page in 6/6 runs.
This commit is contained in:
commit
175ba52cf3
141 changed files with 3736 additions and 1421 deletions
|
|
@ -10,7 +10,7 @@
|
|||
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
|
||||
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
|
||||
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",
|
||||
"ways_in_are_country_level": "The ways in are chosen for the COUNTRY, and going below the border is what exposes the cost of that. Arunachal Pradesh, in the far north-east, is offered TELUGU — a South Indian language — because the curated bridge table names it for those groups and a curated bridge outranks the computed one. India's computed default is Hindi (41%, official), which is defensible nationally and still wrong for much of the north-east. This table does not fix that: it makes it visible. Until a bridge can be chosen per province, read the ways-in column as \"a language with Scripture and film somewhere in this country\", not as a recommendation for this place.",
|
||||
"ways_in": "Chosen FOR THE PROVINCE where the province can answer: the languages of groups standing in it that already have Scripture AND film, ranked by the people here who already hold them. The column says which basis was used. This replaces a country-level answer that produced a measured absurdity — Arunachal Pradesh, in the far north-east, was offered TELUGU, because the curated bridge table is keyed by country and a curated bridge outranks a computed one. A province with no tier-2 language of its own still falls back to the country answer, and ways_in_basis says so rather than hiding it. It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a question for someone on the ground.",
|
||||
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
|
||||
},
|
||||
"columns": [
|
||||
|
|
@ -49,11 +49,36 @@
|
|||
"field": "zero_access_examples",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Nobody praying",
|
||||
"field": "nobody_praying_groups",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"title": "Of those with a prayer page",
|
||||
"field": "prayer_pages",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"title": "People nobody is praying for",
|
||||
"field": "nobody_praying_people",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"title": "Ways in",
|
||||
"field": "ways_in",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Basis",
|
||||
"field": "ways_in_basis",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Ways in (country-level)",
|
||||
"field": "ways_in_country",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Placement approximate",
|
||||
"field": "approximate",
|
||||
|
|
@ -70,7 +95,18 @@
|
|||
"zero_access_groups": 14,
|
||||
"zero_access_people": 602850,
|
||||
"zero_access_examples": "Hongjin Tai; Wenma Zhuang; Mosuo",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 45,
|
||||
"pray_first": {
|
||||
"name": "Hongjin Tai",
|
||||
"url": "https://pray.doxa.life/hongjin-tai?source=doxalife&utm_source=agwm",
|
||||
"people": 209000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 35,
|
||||
"nobody_praying_people": 1430325,
|
||||
"ways_in": "Chinese, Mandarin (63,825 here); Tai Dam (48,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -82,7 +118,18 @@
|
|||
"zero_access_groups": 7,
|
||||
"zero_access_people": 488900,
|
||||
"zero_access_examples": "Huishui Miao; Yanghuang; Cai",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 28,
|
||||
"pray_first": {
|
||||
"name": "Yanghuang",
|
||||
"url": "https://pray.doxa.life/yanghuang?source=doxalife&utm_source=agwm",
|
||||
"people": 63000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 20,
|
||||
"nobody_praying_people": 560000,
|
||||
"ways_in": "Chinese, Mandarin (332,900 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -94,7 +141,18 @@
|
|||
"zero_access_groups": 7,
|
||||
"zero_access_people": 105700,
|
||||
"zero_access_examples": "Ersu; Minyak; Namuyi",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 10,
|
||||
"pray_first": {
|
||||
"name": "Ersu",
|
||||
"url": "https://pray.doxa.life/ersu?source=doxalife&utm_source=agwm",
|
||||
"people": 48000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 8,
|
||||
"nobody_praying_people": 461600,
|
||||
"ways_in": "Tibetan (48,500 here); Chinese, Mandarin (15,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -106,7 +164,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 500,
|
||||
"zero_access_examples": "Jarawas; Onge",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Onge",
|
||||
"url": "https://pray.doxa.life/onge?source=doxalife&utm_source=agwm",
|
||||
"people": 150,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 69450,
|
||||
"ways_in": "Telugu (67,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -118,7 +187,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 600,
|
||||
"zero_access_examples": "Khamba; Na",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 7,
|
||||
"pray_first": {
|
||||
"name": "Na",
|
||||
"url": "https://pray.doxa.life/na?source=doxalife&utm_source=agwm",
|
||||
"people": 100,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 3,
|
||||
"nobody_praying_people": 7800,
|
||||
"ways_in": "Hindi (5,700 here); Assamese (2,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -130,7 +210,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 31500,
|
||||
"zero_access_examples": "Sarikoli Tajiks; Ainu",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Ainu",
|
||||
"url": "https://pray.doxa.life/ainu-china?source=doxalife&utm_source=agwm",
|
||||
"people": 11000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 215625,
|
||||
"ways_in": "Chinese, Mandarin (25 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -142,7 +233,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 150000,
|
||||
"zero_access_examples": "Eastern Xiangxi Miao; Wunai",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Wunai",
|
||||
"url": "https://pray.doxa.life/wunai?source=doxalife&utm_source=agwm",
|
||||
"people": 14000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 2,
|
||||
"nobody_praying_people": 20000,
|
||||
"ways_in": "Chinese, Mandarin (6,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -154,7 +256,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 12700,
|
||||
"zero_access_examples": "Tongren Bonan; Wutun",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": {
|
||||
"name": "Wutun",
|
||||
"url": "https://pray.doxa.life/wutun?source=doxalife&utm_source=agwm",
|
||||
"people": 3800,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 3800,
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -166,7 +279,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 8600,
|
||||
"zero_access_examples": "Malai Pandaram",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 66,
|
||||
"pray_first": {
|
||||
"name": "Malai Pandaram",
|
||||
"url": "https://pray.doxa.life/malai-pandaram?source=doxalife&utm_source=agwm",
|
||||
"people": 8600,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 53,
|
||||
"nobody_praying_people": 1152750,
|
||||
"ways_in": "Malayalam (2,629,525 here); Kannada (38,200 here); Tamil (33,150 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -178,7 +302,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 5000,
|
||||
"zero_access_examples": "Jadupetia",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Jadupetia",
|
||||
"url": "https://pray.doxa.life/jadupetia?source=doxalife&utm_source=agwm",
|
||||
"people": 5000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 5,
|
||||
"nobody_praying_people": 41900,
|
||||
"ways_in": "Hindi (495,000 here); Bengali (164,200 here); Urdu (94,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -190,7 +325,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 750,
|
||||
"zero_access_examples": "Yerong",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Yerong",
|
||||
"url": "https://pray.doxa.life/yerong?source=doxalife&utm_source=agwm",
|
||||
"people": 750,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 51150,
|
||||
"ways_in": "Chinese, Mandarin (50,400 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -202,7 +348,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 1700,
|
||||
"zero_access_examples": "Koiren",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 3,
|
||||
"pray_first": {
|
||||
"name": "Koiren",
|
||||
"url": "https://pray.doxa.life/koiren?source=doxalife&utm_source=agwm",
|
||||
"people": 1700,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 3,
|
||||
"nobody_praying_people": 112400,
|
||||
"ways_in": "Meitei (319,700 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -214,7 +371,13 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 170000,
|
||||
"zero_access_examples": "Buryat",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -226,7 +389,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 80,
|
||||
"pray_first": {
|
||||
"name": "Bairagi (Gujarati)",
|
||||
"url": "https://pray.doxa.life/bairagi-gujarati?source=doxalife&utm_source=agwm",
|
||||
"people": 510000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 59,
|
||||
"nobody_praying_people": 1429045,
|
||||
"ways_in": "Gujarati (1,874,475 here); Urdu (123,200 here); Marathi (70,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -238,7 +412,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 76,
|
||||
"pray_first": {
|
||||
"name": "Aiyarakulu",
|
||||
"url": "https://pray.doxa.life/aiyarakulu?source=doxalife&utm_source=agwm",
|
||||
"people": 688000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 64,
|
||||
"nobody_praying_people": 3818505,
|
||||
"ways_in": "Telugu (3,561,375 here); Kannada (509,500 here); Odia (378,650 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -250,7 +435,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 75,
|
||||
"pray_first": {
|
||||
"name": "Nai (Kannada)",
|
||||
"url": "https://pray.doxa.life/nai-kannada?source=doxalife&utm_source=agwm",
|
||||
"people": 336000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 55,
|
||||
"nobody_praying_people": 2569950,
|
||||
"ways_in": "Kannada (2,277,625 here); Telugu (666,800 here); Urdu (261,625 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -262,7 +458,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 64,
|
||||
"pray_first": {
|
||||
"name": "Tondman",
|
||||
"url": "https://pray.doxa.life/tondman?source=doxalife&utm_source=agwm",
|
||||
"people": 555000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 50,
|
||||
"nobody_praying_people": 2144700,
|
||||
"ways_in": "Tamil (1,646,150 here); Telugu (823,600 here); Malayalam (224,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -274,7 +481,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 54,
|
||||
"pray_first": {
|
||||
"name": "Rana",
|
||||
"url": "https://pray.doxa.life/rana?source=doxalife&utm_source=agwm",
|
||||
"people": 76500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 41,
|
||||
"nobody_praying_people": 523900,
|
||||
"ways_in": "Odia (1,197,550 here); Bengali (31,500 here); Maithili (17,900 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -286,7 +504,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 52,
|
||||
"pray_first": {
|
||||
"name": "Lonar",
|
||||
"url": "https://pray.doxa.life/lonar?source=doxalife&utm_source=agwm",
|
||||
"people": 427000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 45,
|
||||
"nobody_praying_people": 1967000,
|
||||
"ways_in": "Marathi (2,144,775 here); Telugu (247,225 here); Urdu (181,300 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -298,7 +527,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 45,
|
||||
"pray_first": {
|
||||
"name": "Van Baoriya",
|
||||
"url": "https://pray.doxa.life/van-baoriya?source=doxalife&utm_source=agwm",
|
||||
"people": 442000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 33,
|
||||
"nobody_praying_people": 1549725,
|
||||
"ways_in": "Hindi (61,400 here); Urdu (21,175 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -310,7 +550,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 27,
|
||||
"pray_first": {
|
||||
"name": "Bari (Hindi)",
|
||||
"url": "https://pray.doxa.life/bari-hindi?source=doxalife&utm_source=agwm",
|
||||
"people": 405000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 23,
|
||||
"nobody_praying_people": 1217800,
|
||||
"ways_in": "Hindi (2,060,800 here); Chhattisgarhi (234,000 here); Marathi (73,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -322,7 +573,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 32,
|
||||
"pray_first": {
|
||||
"name": "Bania (Dogri)",
|
||||
"url": "https://pray.doxa.life/bania-dogri?source=doxalife&utm_source=agwm",
|
||||
"people": 146000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 25,
|
||||
"nobody_praying_people": 497665,
|
||||
"ways_in": "Hindi (30,500 here); Urdu (250 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -334,7 +596,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 28,
|
||||
"pray_first": {
|
||||
"name": "Khairwar (Bengali)",
|
||||
"url": "https://pray.doxa.life/khairwar-bengali?source=doxalife&utm_source=agwm",
|
||||
"people": 166000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 16,
|
||||
"nobody_praying_people": 381500,
|
||||
"ways_in": "Bengali (2,146,100 here); Sunwar (38,000 here); Nepali (19,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -346,7 +619,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 22,
|
||||
"pray_first": {
|
||||
"name": "Bolla",
|
||||
"url": "https://pray.doxa.life/bolla?source=doxalife&utm_source=agwm",
|
||||
"people": 244000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 16,
|
||||
"nobody_praying_people": 1155150,
|
||||
"ways_in": "Telugu (1,342,400 here); Marathi (243,000 here); Hindi (73,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -358,7 +642,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 24,
|
||||
"pray_first": {
|
||||
"name": "Badhai (Chhattisgarhi)",
|
||||
"url": "https://pray.doxa.life/badhai-chhattisgarhi?source=doxalife&utm_source=agwm",
|
||||
"people": 843000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 20,
|
||||
"nobody_praying_people": 1818200,
|
||||
"ways_in": "Chhattisgarhi (1,411,850 here); Halbi (39,200 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -370,7 +665,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 19,
|
||||
"pray_first": {
|
||||
"name": "Bargujar",
|
||||
"url": "https://pray.doxa.life/bargujar?source=doxalife&utm_source=agwm",
|
||||
"people": 159000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 12,
|
||||
"nobody_praying_people": 410150,
|
||||
"ways_in": "Hindi (332,550 here); Urdu (227,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -382,7 +688,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"prayer_pages": 18,
|
||||
"pray_first": {
|
||||
"name": "Pangwala",
|
||||
"url": "https://pray.doxa.life/pangwala?source=doxalife&utm_source=agwm",
|
||||
"people": 24000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 13,
|
||||
"nobody_praying_people": 75470,
|
||||
"ways_in": "",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -394,7 +711,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 17,
|
||||
"pray_first": {
|
||||
"name": "Tilli",
|
||||
"url": "https://pray.doxa.life/tilli?source=doxalife&utm_source=agwm",
|
||||
"people": 150000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 14,
|
||||
"nobody_praying_people": 400200,
|
||||
"ways_in": "Hindi (164,000 here); Urdu (82,800 here); Maithili (54,700 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -406,7 +734,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 14,
|
||||
"pray_first": {
|
||||
"name": "Namasudra (Assamese)",
|
||||
"url": "https://pray.doxa.life/namasudra-assamese?source=doxalife&utm_source=agwm",
|
||||
"people": 827000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 11,
|
||||
"nobody_praying_people": 1303400,
|
||||
"ways_in": "Assamese (1,459,800 here); Bengali (910,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -418,7 +757,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 6,
|
||||
"pray_first": {
|
||||
"name": "Za",
|
||||
"url": "https://pray.doxa.life/za?source=doxalife&utm_source=agwm",
|
||||
"people": 3400,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 6550,
|
||||
"ways_in": "Tibetan (85,100 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -430,7 +780,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 9,
|
||||
"pray_first": {
|
||||
"name": "Mahisyadas",
|
||||
"url": "https://pray.doxa.life/mahisyadas?source=doxalife&utm_source=agwm",
|
||||
"people": 190000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 9,
|
||||
"nobody_praying_people": 280500,
|
||||
"ways_in": "Bengali (695,400 here); Odia (3,100 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -442,7 +803,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 7,
|
||||
"pray_first": {
|
||||
"name": "Tagewale",
|
||||
"url": "https://pray.doxa.life/tagewale?source=doxalife&utm_source=agwm",
|
||||
"people": 7000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 6,
|
||||
"nobody_praying_people": 13875,
|
||||
"ways_in": "Hindi (7,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -454,7 +826,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 6,
|
||||
"pray_first": {
|
||||
"name": "Campa",
|
||||
"url": "https://pray.doxa.life/campa?source=doxalife&utm_source=agwm",
|
||||
"people": 7500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 15500,
|
||||
"ways_in": "Ladakhi (11,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -466,7 +849,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Gabit",
|
||||
"url": "https://pray.doxa.life/gabit?source=doxalife&utm_source=agwm",
|
||||
"people": 210000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 252600,
|
||||
"ways_in": "Marathi (4,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -478,7 +872,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Sonavar",
|
||||
"url": "https://pray.doxa.life/sonavar?source=doxalife&utm_source=agwm",
|
||||
"people": 20500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 28625,
|
||||
"ways_in": "Urdu (20,500 here); Tamil (13,950 here); French (6,700 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -490,7 +895,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Lakhera (Haryanvi)",
|
||||
"url": "https://pray.doxa.life/lakhera-haryanvi?source=doxalife&utm_source=agwm",
|
||||
"people": 6700,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 6700,
|
||||
"ways_in": "Punjabi (41,000 here); Haryanvi (26,200 here); Hindi (2,300 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -502,7 +918,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Racband",
|
||||
"url": "https://pray.doxa.life/racband?source=doxalife&utm_source=agwm",
|
||||
"people": 3600,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 3,
|
||||
"nobody_praying_people": 7100,
|
||||
"ways_in": "Punjabi (10,100 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -514,7 +941,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Chinese, Mandarin (16,000 here); Tibetan (5,400 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -526,7 +959,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -538,7 +977,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 2,
|
||||
"pray_first": {
|
||||
"name": "Dalu",
|
||||
"url": "https://pray.doxa.life/dalu-india?source=doxalife&utm_source=agwm",
|
||||
"people": 16500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 2,
|
||||
"nobody_praying_people": 17150,
|
||||
"ways_in": "Bengali (16,500 here); Assamese (650 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -550,7 +1000,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 2,
|
||||
"pray_first": {
|
||||
"name": "Olot",
|
||||
"url": "https://pray.doxa.life/olot?source=doxalife&utm_source=agwm",
|
||||
"people": 2400,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 2400,
|
||||
"ways_in": "Chinese, Mandarin (2,600 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -562,7 +1023,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": {
|
||||
"name": "Khatbune",
|
||||
"url": "https://pray.doxa.life/khatbune?source=doxalife&utm_source=agwm",
|
||||
"people": 1200,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 1200,
|
||||
"ways_in": "Punjabi (1,200 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -574,7 +1046,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Punjabi (34,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -586,7 +1064,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Maldivian (74,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -598,7 +1082,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Gujarati (125 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -610,7 +1100,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": {
|
||||
"name": "Yakha",
|
||||
"url": "https://pray.doxa.life/yakha?source=doxalife&utm_source=agwm",
|
||||
"people": 7400,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 7400,
|
||||
"ways_in": "",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
}
|
||||
],
|
||||
|
|
|
|||
47
documentation/README-embed-gospel-resources.md
Normal file
47
documentation/README-embed-gospel-resources.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Embedding the gospel-resources map
|
||||
|
||||
The map's **URL is its interface**. A partner embeds one address in an iframe and never touches it
|
||||
again, so every parameter below is a promise kept by a gate
|
||||
(`bun run check:embed-contract`), not a convention.
|
||||
|
||||
```html
|
||||
<iframe src="https://<host>/dx-maps/gospel-resources/resources-map/index.html?codes=kfr,cmn,npi"
|
||||
style="width:100%;height:600px;border:0" title="Gospel access"></iframe>
|
||||
```
|
||||
|
||||
## The four addresses
|
||||
|
||||
| Address | What the reader gets |
|
||||
|---|---|
|
||||
| `?codes=<iso3,iso3,…>` | Only those languages' people groups, framed on them. Shows `74 / 2,107`, and that count is the button back to the whole map. |
|
||||
| `?country=<ISO3>` | That country's sheet open, framed on its groups. The country's own name also works (`?country=India`). |
|
||||
| `?code=<iso3>` | The people group's sheet, with its resources. `&name=` and `&country=` disambiguate when a language has many groups. |
|
||||
| `?view=zero` | The groups with **nothing found** — the question this map exists for. `?view=gap` is the same view. |
|
||||
|
||||
`?provider=maplibre` is not this map's parameter but the whole fleet's: it selects the keyless
|
||||
engine, and no map ever substitutes a different one (`bun run check:engine-contract`).
|
||||
|
||||
## Bring your own list
|
||||
|
||||
The join is **ISO 639-3**, the key every resource in this map is already joined on. A partner's list
|
||||
needs no agreement with Doxa's roster — only the codes their own data carries. That is the point:
|
||||
this corpus is public so that other lists can stand on it.
|
||||
|
||||
Two things to know before you publish a link:
|
||||
|
||||
- **The denominator is Doxa's roster.** The research corpus holds 12,379 people groups; this map
|
||||
draws the **2,107** Doxa works, so `?codes=` intersects with those. A code outside the roster
|
||||
draws nothing.
|
||||
- **It filters, it never invents.** A link matching none of its codes shows an empty map reading
|
||||
`0 / 2,107`. That is the truth about the link — showing the full map instead would tell you your
|
||||
broken list works.
|
||||
|
||||
## What the map does not promise
|
||||
|
||||
- **No zoom or centre parameter.** The frame is derived from the pins the link selects, so a link
|
||||
stays correct when the data moves under it.
|
||||
- **Placement is approximate.** A dot marks where a *people* is found, not a town or a boundary,
|
||||
and 108 of the placed groups share a coordinate with another group. The sheet says so in the
|
||||
reader's language.
|
||||
- **An unknown parameter is ignored, never fatal.** `?country=ZZZ` renders the map, not an error —
|
||||
a partner's typo must not break their page.
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
- **[explanation.md](./explanation.md)** — the model in 15 lines.
|
||||
- **[architecture/](./architecture/)** — the deep end: full contracts, the per-file catalog.
|
||||
- **[README-embed-example.md](./README-embed-example.md)** — the drop-in README used when this bundler is embedded inside a host repo.
|
||||
- **[README-embed-gospel-resources.md](./README-embed-gospel-resources.md)** — the gospel map's URL contract for partner embeds: bring your own ISO 639-3 list, one country, one group, or the zero-access view.
|
||||
|
||||
Working on the repo:
|
||||
|
||||
|
|
|
|||
|
|
@ -81,9 +81,9 @@ apply it. Rule of thumb: **a path with ≥2 directory segments under the tree ro
|
|||
page → serve it framable.**
|
||||
|
||||
Portable ways to implement the split, by host:
|
||||
- **Traefik** (nuxtbase/go-base): a higher-priority router on `PathRegexp(^/plugin-assets/[^/]+/[^/]+/)`
|
||||
sets `Content-Security-Policy: frame-ancestors *` + clears `X-Frame-Options`. (Live example:
|
||||
`/data/coolify/proxy/dynamic/doxa-maps-embed.yaml`.)
|
||||
- **Traefik** (a self-hosted reverse proxy): a higher-priority router on `PathRegexp(^/plugin-assets/[^/]+/[^/]+/)`
|
||||
sets `Content-Security-Policy: frame-ancestors *` + clears `X-Frame-Options`. (Live example: a Traefik
|
||||
dynamic-configuration file on the test host.)
|
||||
- **Cloudflare Pages / Netlify**: a `_headers` file — `/<bundle>/*/* Content-Security-Policy: frame-ancestors *`.
|
||||
- **Nginx**: `location ~ ^/[^/]+/[^/]+/ { add_header Content-Security-Policy "frame-ancestors *"; }`
|
||||
- **GitHub Pages**: serves no custom headers → pages are framable by default (no frame-ancestors), which is already correct for direct embeds.
|
||||
|
|
|
|||
|
|
@ -215,8 +215,8 @@ there are options:**
|
|||
site serves them, so **doxa-life is the CDN**. No third party.
|
||||
- **GitHub.** Push the built maps to a public GitHub repo and serve them free through
|
||||
**GitHub Pages or jsDelivr** — **GitHub is the CDN**.
|
||||
- **doxa-base's built-in home (Forgejo).** doxa-base ships **Forgejo as a CDN** — push the maps
|
||||
there and it serves them, with no extra service to set up.
|
||||
- **A Git host you run (for example Forgejo).** Push the maps there and serve them from it, with
|
||||
no extra service to set up.
|
||||
- **Anywhere else.** Any static host works — Cloudflare, an S3-style bucket, etc.
|
||||
|
||||
Pick one home and **every partner site embeds the same map from it** — change the map once and it
|
||||
|
|
|
|||
|
|
@ -28,9 +28,11 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/charts/BarChart.vue'`
|
||||
- **PinHint** — PinHint.vue — the conversion coach-mark (bottom-center of any map).
|
||||
`import … from '@map/components/hints/PinHint.vue'`
|
||||
- **ApproxPinNote** — ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
|
||||
`import … from '@map/components/legends/ApproxPinNote.vue'`
|
||||
- **LegendDesktop** — This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
|
||||
`import … from '@map/components/legends/LegendDesktop.vue'`
|
||||
- **LegendMobile** — useShadowStyles(
|
||||
- **LegendMobile** — (no description)
|
||||
`import … from '@map/components/legends/LegendMobile.vue'`
|
||||
- **LegendProgressBar** — LegendProgressBar — the prayer legend's header progress bar.
|
||||
`import … from '@map/components/legends/LegendProgressBar.vue'`
|
||||
|
|
@ -115,6 +117,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useAvatarGenerator.js'`
|
||||
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
|
||||
`import … from '@map/composables/useCountryOutline.js'`
|
||||
- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
|
||||
`import … from '@map/composables/useDashboardLayout.js'`
|
||||
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
|
||||
`import … from '@map/composables/useDocumentFont.js'`
|
||||
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
|
||||
|
|
@ -155,6 +159,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useSearch.js'`
|
||||
- **useSelectedPin** — useSelectedPin.js - Selected Pin Highlight + Animated GO Marker
|
||||
`import … from '@map/composables/useSelectedPin.js'`
|
||||
- **useSelectionBus** — useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus.
|
||||
`import … from '@map/composables/useSelectionBus.js'`
|
||||
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
|
||||
`import … from '@map/composables/useShadowStyles.js'`
|
||||
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
|
||||
|
|
@ -172,6 +178,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/NetworkClusteringUtil.js'`
|
||||
- **apiBaseUrl** — Back-compat shim. The real apiBaseUrl.js lives in the api/ seam folder.
|
||||
`import … from '@map/utils/apiBaseUrl.js'`
|
||||
- **colocation** — colocation.js — which people groups share ONE coordinate on a map (APPROXPIN, 2026-09-26).
|
||||
`import … from '@map/utils/colocation.js'`
|
||||
- **countries-meta** — countries-meta.js — a PORT of DX-life's config/countries-meta.ts (326 lines), the rollup
|
||||
`import … from '@map/utils/countries-meta.js'`
|
||||
- **embedBase** — embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ const eq = (label, got, want) => (String(got) === String(want) ? ok(`${label} =
|
|||
// changed. The accessors are re-derived here from the same row fields the config declares, and
|
||||
// assertion A cross-checks the extracted drill arrays against EXPECT, so a silent extraction
|
||||
// failure shows up as a named failure rather than a skipped check.
|
||||
const BUS_SRC = join(ROOT, 'plugin-designs/research-dashboard/src/composables/useSelectionBus.js')
|
||||
const BUS_SRC = join(ROOT, 'library/composables/useSelectionBus.js')
|
||||
const CFG_SRC = join(ROOT, 'plugin-designs/research-dashboard/dashboard.config.js')
|
||||
|
||||
const pathToUrl = (p) => 'file://' + p + '?t=' + Date.now()
|
||||
|
|
|
|||
160
internal/check-gospel-engine-contract.mjs
Normal file
160
internal/check-gospel-engine-contract.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* check-gospel-engine-contract.mjs — the map must use the engine it was ASKED for, or say nothing
|
||||
* at all. Never a different one.
|
||||
*
|
||||
* THE DEFECT THIS IS WRITTEN AGAINST (regions' C3 read + upg100's addendum, 2026-09-26).
|
||||
* resources-map resolved its engine as `engineGlobal(provider) || window.mapboxgl`. engineGlobal
|
||||
* already defaults to mapbox, so that fallback could only fire for provider 'maplibre' — and then
|
||||
* it returned MAPBOX. A page that chose the keyless engine silently got the one that needs a
|
||||
* token; and since the profile only assigns accessToken when the provider is not maplibre, the
|
||||
* substitute had no token either, so it would 401 on every tile. Two failures compounding, both
|
||||
* invisible to every other gate: the leaf mounts, renders and logs no console error.
|
||||
*
|
||||
* SIX MEASUREMENTS: two providers x three load states.
|
||||
* engine present -> a canvas, drawn by the engine that was asked for
|
||||
* engine blocked -> NO canvas, a visible message naming that engine, and — the assertion
|
||||
* that matters — the OTHER engine's global must not be the one in use
|
||||
* both blocked -> same, and never a blank with nothing said
|
||||
*
|
||||
* The CDN script is blocked with request routing rather than by editing anything: that is exactly
|
||||
* what a corporate proxy, an offline kiosk or a broken CDN does to a partner's embed.
|
||||
*
|
||||
* Self-test: `--poison` restores the old `|| window.mapboxgl` behaviour in the page (by defining
|
||||
* window.mapboxgl before the bundle runs while maplibre is blocked) and requires the maplibre arm
|
||||
* to FAIL — if it passes, this check cannot see the defect it exists for.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { serveReaped } from './_serve-reaped.mjs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PUBLIC = resolve(HERE, '../public')
|
||||
const PORT = Number(process.env.PORT || 5395)
|
||||
const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`
|
||||
const POISON = process.argv.includes('--poison')
|
||||
const TAG = 'gospel-resources-map'
|
||||
const LEAF = `${BASE}/${TREE_NAME}/gospel-resources/resources-map/index.html`
|
||||
const SCRIPT = { mapbox: /mapbox-gl.*\.js/i, maplibre: /maplibre-gl.*\.js/i }
|
||||
|
||||
let srv = null
|
||||
if (!process.env.BASE_URL) srv = serveReaped(PUBLIC, PORT)
|
||||
|
||||
const run = async (browser, provider, block) => {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
|
||||
const warns = [], errors = []
|
||||
page.on('console', (m) => { if (m.type() === 'warning' || m.type() === 'error') warns.push(m.text().slice(0, 200)) })
|
||||
page.on('pageerror', (e) => errors.push(String(e).slice(0, 200)))
|
||||
for (const b of block) await page.route(SCRIPT[b], (r) => r.abort())
|
||||
/* ⚠ MAPBOX CANNOT EVEN MOUNT WITHOUT A TOKEN, and that is the environment, not the product: a
|
||||
static tree has no /api/maps/token, so the profile loader never renders the element and the
|
||||
first version of this check read that as "the leaf did not mount". A dummy token gets the
|
||||
element on the page; its tiles 401, which is irrelevant here — this check is about WHICH
|
||||
engine was constructed, and Mapbox GL creates its canvas either way. */
|
||||
if (provider === 'mapbox') await page.addInitScript(() => { window.MAP_TOKEN = 'pk.test.not-a-real-token' })
|
||||
await page.goto(`${LEAF}?provider=${provider}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||
/* 20s, not 11: this leaf fetches ~4 MB of tables before the map exists, and at 11s three of
|
||||
six runs were still showing the profile loader — the gate was measuring its own impatience. */
|
||||
await page.waitForTimeout(20000)
|
||||
const r = await page.evaluate((tag) => {
|
||||
const sr = document.querySelector(tag)?.shadowRoot
|
||||
const txt = sr ? (sr.textContent || '').replace(/\s+/g, ' ').trim() : ''
|
||||
return {
|
||||
mounted: !!sr,
|
||||
canvas: !!sr?.querySelector('canvas'),
|
||||
text: txt.slice(0, 600),
|
||||
/* The leaf's own error line. Reading the whole shadow text instead caught the stylesheet —
|
||||
".profile-loader .profile-error" — and scored a rendered message as a blank. */
|
||||
errText: (sr?.querySelector('.gm-err')?.textContent || '').trim(),
|
||||
/* WHICH global is actually present on the page — the substitution is invisible in the DOM. */
|
||||
hasMapbox: !!window.mapboxgl,
|
||||
hasMaplibre: !!window.maplibregl,
|
||||
/* WHICH ENGINE DREW IT, read off the canvas's own class — each engine stamps its name
|
||||
(.mapboxgl-canvas / .maplibregl-canvas). Read from window.__grMap first, this scored
|
||||
Mapbox-with-a-dummy-token as "engine null": the canvas exists, the style 401s, and the
|
||||
debug handle is never assigned. The canvas cannot lie about who created it. */
|
||||
engineOfMap: (() => {
|
||||
const c = sr?.querySelector('canvas')
|
||||
const cls = c ? c.className || '' : ''
|
||||
if (/maplibregl/.test(cls)) return 'maplibre'
|
||||
if (/mapboxgl/.test(cls)) return 'mapbox'
|
||||
const m = window.__grMap
|
||||
if (m && window.maplibregl && m instanceof window.maplibregl.Map) return 'maplibre'
|
||||
if (m && window.mapboxgl && m instanceof window.mapboxgl.Map) return 'mapbox'
|
||||
return c ? 'unknown' : null
|
||||
})(),
|
||||
}
|
||||
}, TAG)
|
||||
await page.close()
|
||||
return { ...r, warns, errors }
|
||||
}
|
||||
|
||||
const fails = []
|
||||
|
||||
/* ═══ THE STATIC ARM, AND WHY THE SELF-TEST LIVES HERE ════════════════════════════════════════
|
||||
* The six runtime measurements are the behavioural evidence, but they cannot be made to fail on
|
||||
* demand: with the fallback deleted, planting a substitute engine on the PAGE proves nothing —
|
||||
* the leaf correctly ignores it, so the arms pass and a --poison that expected a failure was
|
||||
* asserting the wrong thing (measured: it reported "this check cannot see the defect", about
|
||||
* itself). The defect is a line of SOURCE, so the falsifiable arm reads the source. */
|
||||
const LEAF_SRC = resolve(HERE, '../plugin-designs/gospel-resources/resources-map.vue')
|
||||
let src = readFileSync(LEAF_SRC, 'utf8')
|
||||
if (POISON) src = src.replace('const gl = engineGlobal(grProvider)\n', 'const gl = engineGlobal(grProvider) || window.mapboxgl\n')
|
||||
/* COMMENTS STRIPPED FIRST. The fix's own comment QUOTES the deleted line — "this used to read
|
||||
`engineGlobal(grProvider) || window.mapboxgl`" — and the first version of this arm read that
|
||||
quotation as the defect, failing the very commit that removed it. A gate that cannot tell code
|
||||
from prose about code will keep flagging the explanation forever. */
|
||||
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"\\])\/\/[^\n]*/g, '$1')
|
||||
const BAD = /engineGlobal\([^)]*\)\s*\|\|/
|
||||
if (BAD.test(code)) {
|
||||
fails.push('resources-map.vue falls back from engineGlobal() to another engine global — a page that asked for the keyless engine would silently get the one that needs a token')
|
||||
}
|
||||
if (POISON) {
|
||||
if (!fails.length) { console.error('ENGINE-CONTRACT --poison: the reintroduced `|| window.mapboxgl` was NOT caught — this check cannot see the defect it exists for.'); process.exit(1) }
|
||||
console.log(`ENGINE-CONTRACT --poison: caught it — ${fails[0]}`)
|
||||
console.log('ENGINE-CONTRACT: SELF-TEST PASS — the static arm fails as designed. The six runtime measurements are not re-run here; they cannot be forced to fail without reintroducing the defect in a built bundle.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let browser
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
for (const provider of ['mapbox', 'maplibre']) {
|
||||
/* 1 — the engine is there. It must be the one asked for. */
|
||||
const ok = await run(browser, provider, [])
|
||||
if (!ok.mounted) fails.push(`${provider}/present: the leaf did not mount — errors: ${ok.errors.join(' | ') || 'none'}; warns: ${ok.warns.slice(0,2).join(' | ')}`)
|
||||
else if (!ok.canvas) fails.push(`${provider}/present: no canvas — the map did not draw`)
|
||||
else if (ok.engineOfMap !== provider && ok.engineOfMap !== 'unknown') fails.push(`${provider}/present: the map instance came from ${ok.engineOfMap}`)
|
||||
else console.log(` ok ${provider}/present`.padEnd(30) + `canvas, engine=${ok.engineOfMap}`)
|
||||
|
||||
/* 2 — the engine is blocked. Visible failure, and NEVER the other engine. */
|
||||
const gone = await run(browser, provider, [provider])
|
||||
const other = provider === 'maplibre' ? 'mapbox' : 'maplibre'
|
||||
if (!gone.mounted) fails.push(`${provider}/blocked: the leaf did not mount at all — a missing engine must not kill the element`)
|
||||
else if (gone.canvas) fails.push(`${provider}/blocked: a canvas was drawn with ${provider} blocked — something else drew it (engine=${gone.engineOfMap})`)
|
||||
else if (gone.engineOfMap === other) fails.push(`${provider}/blocked: the map was built from ${other} — a page that asked for ${provider} got the other engine`)
|
||||
else if (!/did not load|engine/i.test(gone.errText)) fails.push(`${provider}/blocked: nothing on screen says the engine failed — the reader gets a blank (.gm-err reads "${gone.errText}")`)
|
||||
else if (!gone.warns.some((w) => /engine global never loaded/.test(w))) fails.push(`${provider}/blocked: no console warning naming the engine`)
|
||||
else console.log(` ok ${provider}/blocked`.padEnd(30) + 'no canvas, names the engine, no substitution')
|
||||
|
||||
/* 3 — neither engine can load. */
|
||||
const both = await run(browser, provider, ['mapbox', 'maplibre'])
|
||||
if (!both.mounted) fails.push(`${provider}/both-blocked: the leaf did not mount`)
|
||||
else if (both.canvas) fails.push(`${provider}/both-blocked: a canvas appeared with both engines blocked`)
|
||||
else if (!/did not load|engine/i.test(both.errText)) fails.push(`${provider}/both-blocked: a blank with nothing said`)
|
||||
else console.log(` ok ${provider}/both-blocked`.padEnd(30) + 'no canvas, message shown')
|
||||
}
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`ENGINE-CONTRACT: FAIL — ${fails.length} of 6 measurement(s):`)
|
||||
for (const f of fails) console.error(` ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('ENGINE-CONTRACT: PASS — the source keeps no cross-engine fallback, and 2 providers x 3 load states: the engine asked for or a named failure, never a substitute.')
|
||||
} finally {
|
||||
try { await browser?.close() } catch (_) {}
|
||||
try { srv?.close?.() } catch (_) {}
|
||||
}
|
||||
process.exit(0)
|
||||
111
internal/check-provider-for.mjs
Normal file
111
internal/check-provider-for.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/env node
|
||||
/* check-provider-for.mjs — run as `bun run check:provider-for`. Source-only, headless, ~0.1s.
|
||||
Placed in internal/ with studio's explicit authorisation for this one gate (END-STU-C3AB): it spans
|
||||
library/ plus three separate plugins, so no single plugin's scripts/ folder owns it. */
|
||||
// C3: do the library helpers behave like the raw-global code they replace, on BOTH engine shapes AND
|
||||
// on the ambiguous case that is neither?
|
||||
//
|
||||
// Same condition the shepherd set for C2: an equivalence gate, old pattern vs new, both engines. The
|
||||
// three migrated sites, verbatim in shape from main 1a761741:
|
||||
// A1 pplrData.js:115 window.maplibregl && window.maplibregl.Map && map instanceof window.maplibregl.Map ? 'maplibre' : 'mapbox'
|
||||
// A2 research-map.vue:1322 !!(window.maplibregl && map.value instanceof window.maplibregl.Map)
|
||||
// B all-people-groups:298 mapProvider === 'maplibre' ? window.maplibregl : window.mapboxgl
|
||||
//
|
||||
// ★ THE AMBIGUOUS CASE IS THE POINT OF THIS GATE, and it nearly cost a silent behaviour change.
|
||||
// My first plan was to rewrite A1 and A2 in terms of the EXISTING isMapboxMap(). That is wrong in a
|
||||
// way no per-engine test would have caught: both originals test for MAPLIBRE and fall through to
|
||||
// mapbox/false, so a stub, a destroyed map, or a page where neither global loaded reads as 'mapbox'.
|
||||
// An isMapboxMap() rewrite inverts the fall-through to 'maplibre'/true — changing what a diagnostic
|
||||
// field reports and which attribution a poster PRINTS. Hence providerFor() returns undefined and each
|
||||
// call site keeps its own default visible. Changing a default is not a curation migration.
|
||||
//
|
||||
// ★ AND THE PREMISE I HAD WRONG BEFORE THAT: I recorded in SPEC-LIBRARY-CURATION that "engineFor
|
||||
// returns a NAME". It does not — it returns the engine NAMESPACE OBJECT (`return ml` / `return mb`).
|
||||
// Two of my four bucket-D "must not migrate" conclusions rested on that false premise. Corrected in
|
||||
// the spec; the lesson is that a helper's RETURN TYPE is a fact to read, not to infer from its name.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { providerFor, engineFor, isMapboxMap } from '../library/composables/useMapProvider.js'
|
||||
|
||||
// ★ engineGlobal CANNOT BE IMPORTED HERE, and the workaround matters. It lives in useMapInstance.js,
|
||||
// whose import chain reaches library/i18n/index.js, which calls `import.meta.glob` -- a VITE
|
||||
// transform, so plain Node throws "(intermediate value).glob is not a function". C2's gate never hit
|
||||
// this because useMapProvider.js has no such chain.
|
||||
// So this EXTRACTS AND EXECUTES engineGlobal's REAL SOURCE LINES rather than a paraphrase of them.
|
||||
// Re-typing a three-line function into this gate would test my copy, and a copy silently drifts from
|
||||
// the thing it stands for -- the same trap as a tool copied to a new filename that no-ops and exits 0.
|
||||
// CDN is stubbed because engineGlobal reads only `.global` from the ENGINES table; the urls are
|
||||
// irrelevant to it, which is checkable in the extracted text printed below on failure.
|
||||
const umiSrc = readFileSync(new URL('../library/composables/useMapInstance.js', import.meta.url), 'utf8')
|
||||
const enginesBlock = /const ENGINES = \{[\s\S]*?\n\}/.exec(umiSrc)
|
||||
const engineGlobalSrc = /export function engineGlobal\(provider\) \{[\s\S]*?\n\}/.exec(umiSrc)
|
||||
if (!enginesBlock || !engineGlobalSrc) {
|
||||
console.error(' FAIL - could not extract ENGINES / engineGlobal from useMapInstance.js -- the gate is NOT MEASURED, not passing')
|
||||
process.exit(2)
|
||||
}
|
||||
const engineGlobal = new Function(
|
||||
'const CDN = { mapboxGlJs:{url:""}, mapboxGlCss:{url:""}, maplibreGlJs:{url:""}, maplibreGlCss:{url:""} };\n'
|
||||
+ enginesBlock[0] + ';\n'
|
||||
+ engineGlobalSrc[0].replace(/^export /, '') + ';\n'
|
||||
+ 'return engineGlobal;'
|
||||
)()
|
||||
|
||||
let fails = 0
|
||||
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
|
||||
|
||||
class MapboxMap {}
|
||||
class MaplibreMap {}
|
||||
globalThis.window = { mapboxgl: { Map: MapboxMap }, maplibregl: { Map: MaplibreMap } }
|
||||
|
||||
// the THREE old patterns, as they stood
|
||||
const oldA1 = (map) => (window.maplibregl && window.maplibregl.Map && map instanceof window.maplibregl.Map ? 'maplibre' : 'mapbox')
|
||||
const oldA2 = (map) => !!(window.maplibregl && map instanceof window.maplibregl.Map)
|
||||
const oldB = (provider) => (provider === 'maplibre' ? window.maplibregl : window.mapboxgl)
|
||||
// the NEW expressions, exactly as they now read at the call sites
|
||||
const newA1 = (map) => providerFor(map) || 'mapbox'
|
||||
const newA2 = (map) => providerFor(map) === 'maplibre'
|
||||
const newB = (provider) => engineGlobal(provider)
|
||||
|
||||
// ── 1. A1 and A2 across every map shape, INCLUDING neither ──────────────────────────────────────
|
||||
const CASES = [
|
||||
['a Mapbox map', new MapboxMap()],
|
||||
['a MapLibre map', new MaplibreMap()],
|
||||
['AMBIGUOUS: a plain object that is neither', {}],
|
||||
['AMBIGUOUS: null', null],
|
||||
['AMBIGUOUS: undefined', undefined]
|
||||
]
|
||||
for (const [label, map] of CASES) {
|
||||
ok(oldA1(map) === newA1(map), `A1 ${label}: old "${oldA1(map)}" === new "${newA1(map)}"`)
|
||||
ok(oldA2(map) === newA2(map), `A2 ${label}: old ${oldA2(map)} === new ${newA2(map)}`)
|
||||
}
|
||||
|
||||
// ── 2. B across every provider name, including the unrecognised one ─────────────────────────────
|
||||
for (const p of ['maplibre', 'mapbox', 'auto', '', undefined, 'nonsense']) {
|
||||
ok(oldB(p) === newB(p), `B provider=${JSON.stringify(p)}: both resolve to the same namespace (${oldB(p) === window.maplibregl ? 'maplibregl' : 'mapboxgl'})`)
|
||||
}
|
||||
|
||||
// ── 3. THE INVERSION THIS GATE EXISTS TO PREVENT ────────────────────────────────────────────────
|
||||
// Documented as an executable claim, not as prose: isMapboxMap() is NOT a drop-in for these sites.
|
||||
const wouldHaveBeen = (map) => (isMapboxMap(map) ? 'mapbox' : 'maplibre')
|
||||
ok(wouldHaveBeen({}) !== oldA1({}),
|
||||
`PROOF: the isMapboxMap() rewrite I nearly shipped disagrees with the original on the ambiguous case (it says "${wouldHaveBeen({})}", the original says "${oldA1({})}")`)
|
||||
ok(wouldHaveBeen(new MapboxMap()) === oldA1(new MapboxMap()) && wouldHaveBeen(new MaplibreMap()) === oldA1(new MaplibreMap()),
|
||||
'and it agrees on BOTH real engines -- which is exactly why a per-engine-only gate would have passed it')
|
||||
|
||||
// ── 4. return TYPES, since inferring one from a name is what went wrong ─────────────────────────
|
||||
ok(typeof providerFor(new MaplibreMap()) === 'string', 'providerFor returns a STRING name')
|
||||
ok(providerFor(new MaplibreMap()) === 'maplibre' && providerFor(new MapboxMap()) === 'mapbox', 'and the right one per engine')
|
||||
ok(providerFor({}) === undefined, 'and undefined for neither -- never a guessed default')
|
||||
ok(engineFor(new MaplibreMap()) === window.maplibregl, 'engineFor returns the NAMESPACE OBJECT, not a name (the premise I had backwards)')
|
||||
ok(typeof engineGlobal('maplibre') === 'object', 'engineGlobal returns a namespace OBJECT from a name')
|
||||
ok(/ENGINES\[provider\]\?\.global \|\| ENGINES\.mapbox\.global/.test(engineGlobalSrc[0]),
|
||||
'CONTROL: the EXTRACTED source really is the mapbox-defaulting version -- this is what makes the bucket-C fallback finding true, and it is read from the file, not remembered')
|
||||
|
||||
// ── 5. NEG CONTROLS: the comparisons above must be capable of failing ───────────────────────────
|
||||
ok(oldA1(new MaplibreMap()) !== oldA1(new MapboxMap()), 'NEG CONTROL: A1 discriminates the two engines at all')
|
||||
ok(oldB('maplibre') !== oldB('mapbox'), 'NEG CONTROL: B discriminates the two namespaces at all')
|
||||
const brokenNewA1 = () => 'mapbox' // a helper that always says mapbox
|
||||
ok(!CASES.every(([, m]) => oldA1(m) === brokenNewA1(m)),
|
||||
'NEG CONTROL: a helper that always answered "mapbox" would FAIL this gate -- so passing means something')
|
||||
|
||||
console.log(fails ? `\nC3-EQUIVALENCE: ${fails} FAIL(s)` : '\nC3-EQUIVALENCE: all three migrated sites match the raw-global code they replace, on both engines and on every ambiguous input')
|
||||
process.exit(fails ? 1 : 0)
|
||||
|
|
@ -243,6 +243,25 @@ try {
|
|||
if (!scanned) fails.push('pin index carries no links at all — this check would pass vacuously')
|
||||
if (bad.length) fails.push(`${bad.length} link(s) on an unscreened publisher (e.g. ${bad[0]})`)
|
||||
if (contactBad.length) fails.push(`${contactBad.length} translator contact(s) on an unscreened organisation (e.g. ${contactBad[0]})`)
|
||||
|
||||
/* ★ THE PROVINCE TABLE IS A SIXTH PLACE URLS LIVE (2026-09-26). Its rows now carry a prayer
|
||||
page — the first move for that state — and a table added after this scan was written is
|
||||
exactly how the access table went unscreened for weeks. Read from DISK, like the access
|
||||
table below, so it is checked even when the province layer was never opened in the page. */
|
||||
try {
|
||||
const prov = JSON.parse(readFileSync(resolve(REPO, 'data/tables/gospel-by-province.json'), 'utf8'))
|
||||
let provScanned = 0
|
||||
const provBad = []
|
||||
for (const row of prov.rows || []) {
|
||||
const u = row.pray_first && row.pray_first.url
|
||||
if (!u) continue
|
||||
provScanned++
|
||||
if (!ALLOWED.test(u)) provBad.push(`${row.province} -> ${u}`)
|
||||
}
|
||||
if (!provScanned) fails.push('the province table carries no prayer link at all — this arm would pass vacuously')
|
||||
if (provBad.length) fails.push(`${provBad.length} province prayer link(s) on an unscreened host (e.g. ${provBad[0]})`)
|
||||
linkCount += provScanned
|
||||
} catch (e) { fails.push(`could not screen the province table: ${e.message}`) }
|
||||
contactCount = contacts
|
||||
linkCount = scanned
|
||||
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ watch(() => config.value?.tokenUrl, (url) => {
|
|||
// host override via profile-config.dataSource. (doxa-csv stays the final offline fallback.)
|
||||
const dataSource = computed(() => config.value?.dataSource || sourcesConfig.activeSource || 'doxa-csv')
|
||||
const colorSet = computed(() => config.value?.colorSet || 'default')
|
||||
// QA corner widget: OPT-IN via profile-config.qa (default OFF). On for the DOXA go-base
|
||||
// QA corner widget: OPT-IN via profile-config.qa (default OFF). On for our own
|
||||
// surfaces (SPA + plugins); OFF for partner embeds so no QA button appears on their sites.
|
||||
const showQa = computed(() => !!QaCorner && config.value?.qa === true)
|
||||
// Conversion coach-mark: DEFAULT ON (opt-out via profile-config pinHint:false).
|
||||
|
|
|
|||
|
|
@ -16,7 +16,15 @@ const props = defineProps({
|
|||
shared: { type: Array, default: () => [] },
|
||||
dark: { type: Boolean, default: false },
|
||||
})
|
||||
const { t } = useI18n()
|
||||
// i18n is OPTIONAL here, as in PinHint: an unguarded useI18n() throws on a host that never installed
|
||||
// vue-i18n, inside the custom-element boundary, and the reader gets an empty shadow root. Without it
|
||||
// the English below renders, with the same placeholders filled.
|
||||
const APPROX_FALLBACK = {
|
||||
'pins.approximate': 'The dot marks where this people is found, not a town or a boundary.',
|
||||
'pins.sharedPoint': 'It sits on the same point as {names} — {n} groups share this coordinate, so the position tells you the locality and not which group is where.',
|
||||
}
|
||||
let t = (key, vars = {}) => (APPROX_FALLBACK[key] || key).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m))
|
||||
try { t = useI18n().t } catch (_) { /* no i18n on this host */ }
|
||||
const second = computed(() => props.shared.length
|
||||
? t('pins.sharedPoint', { names: props.shared.join(', '), n: props.shared.length + 1 })
|
||||
: '')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Visible shell of the QA-agent bridge (see _qa-cycle/01-ideation-research/
|
||||
* qa-loops/qa-embeddable-qa-agent-bridge.md). OIDC-gated onboarding — NEVER a key.
|
||||
*
|
||||
* AUTH: the widget detects the go-base session via a same-origin GET /api/auth/me.
|
||||
* AUTH: the widget detects the host's session via a same-origin GET /api/auth/me.
|
||||
* That only succeeds when the plugin iframe is SAME-ORIGIN (allow-same-origin). When
|
||||
* the app-shell runs with NUXT_PUBLIC_PLUGIN_SANDBOX_ISOLATE_ORIGIN=true the frame is
|
||||
* origin-'null' and the call is blocked — login is then impossible from inside the
|
||||
|
|
|
|||
548
library/composables/useDashboardLayout.js
Normal file
548
library/composables/useDashboardLayout.js
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
/**
|
||||
* useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
|
||||
*
|
||||
* ★ SHARED AS OF DASHBOARD-KIT LANDING 2. The second piece of Big Picture to become library code,
|
||||
* and the second on the same test: it imports nothing but `vue` and touches no domain field. The
|
||||
* four mentions of a people-groups grid below are comments naming an EXAMPLE of what a host stacks
|
||||
* underneath; nothing here reads a row.
|
||||
*
|
||||
* The body is what research-dashboard shipped, with one behaviour change that the spec asked for
|
||||
* BEFORE this file could be shared — see the storage-key note on `storageKey` below. Everything
|
||||
* else is verbatim.
|
||||
*
|
||||
* plugin-designs/research-dashboard/src/composables/useDashboardLayout.js re-exports this for one
|
||||
* cycle. See _qa-cycle/03-deployment-cycle/SPEC-DASHBOARD-KIT.md section 8, landing 2.
|
||||
*
|
||||
* A dashboard shell (see plugin-designs/research-dashboard/research-dashboard.vue) is a CSS
|
||||
* grid whose three flexible tracks are driven by CSS custom properties:
|
||||
* --facet-w (left facet-rail column width) default 296px
|
||||
* --rail-w (right charts-rail column width) default 296px — equals --facet-w, so the map column sits centred
|
||||
* --table-h (bottom table row height) default 34% (map above it is the 1fr hero)
|
||||
*
|
||||
* This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge),
|
||||
* clamps each to a sane min/max, writes the vars onto the shell root element as INLINE custom
|
||||
* properties, PERSISTS the chosen layout to localStorage on every drag-end, RESTORES it on init,
|
||||
* and offers reset() to clear the key and snap back to the exact defaults.
|
||||
*
|
||||
* SHELL vs. TABLE axis (page-scroll mode only — BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER):
|
||||
* Two DIFFERENT questions look similar and must stay answered by two DIFFERENT handles.
|
||||
* "table" axis = the MAP/TABLE SPLIT. In page-scroll mode it redistributes height BETWEEN
|
||||
* the map and the table; the sum of the two (the element's total height)
|
||||
* does NOT change. This is the EXISTING map/table divider
|
||||
* (.rd-rz-table in research-dashboard.vue), unchanged in grid-mode.
|
||||
* "shell" axis = the ELEMENT'S OWN HEIGHT (page-scroll mode only) — a bar at the shell's
|
||||
* BOTTOM edge, the boundary between the whole research-dashboard element and
|
||||
* whatever a host stacks below it (e.g. the people-groups grid in
|
||||
* dashboard-plus-grid.vue). Dragging it changes --rd-shell-h, the total map+
|
||||
* table budget; the map/table SPLIT within that budget is untouched.
|
||||
* --rd-shell-h is the new independent variable; --rd-map-h is now the map's share of it, and
|
||||
* the table's share is derived as shellH - mapH (see the CSS in research-dashboard.vue). Before
|
||||
* this, --rd-map-h alone was BOTH "the map/table split" AND "the element's total height" — one
|
||||
* knob answering two questions, which is why dragging the map/table divider used to resize the
|
||||
* whole element as a side effect. Driver: "you improperly bound that control to the separator
|
||||
* line between the map and the table... that's not the request I was making to resize the
|
||||
* height of the page."
|
||||
*
|
||||
* COMPOSITION with the collapse-table fix (do NOT regress):
|
||||
* The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule
|
||||
* `.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0 }` comes LATER in source
|
||||
* order (same specificity) so COLLAPSE WINS over --table-h. Because we only touch the CSS var
|
||||
* (never the collapsed row template), collapsing hides the table regardless of a dragged height,
|
||||
* and expanding restores the user's dragged --table-h. The center map is 1fr and already
|
||||
* self-resizes via DashboardMapPanel's ResizeObserver → map.resize(); we add no second resize.
|
||||
*
|
||||
* NON-REGRESSION with the responsive slim rails:
|
||||
* `.rd-shell.rd-slim-rail{ --rail-w:228px }` / `.rd-shell.rd-slim-facet{ --facet-w:250px }` are
|
||||
* medium-width niceties set via CLASS. Inline styles always beat class rules, so we apply inline
|
||||
* vars ONLY once the layout is customized (restored-from-storage or dragged). An untouched
|
||||
* dashboard writes no inline vars → the slim classes and the exact CSS defaults are preserved.
|
||||
* reset() removes the inline vars entirely, fully handing control back to the stylesheet.
|
||||
*
|
||||
* DESKTOP-ONLY: every drag is gated on `!isNarrow`; narrow/mobile never resizes or persists.
|
||||
*
|
||||
* Library-parameterizable — the profile's identity (defaults + storage key) lives in options, not
|
||||
* here. Any dashboard grid exposing the same three vars can reuse this unchanged.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import('vue').Ref<HTMLElement|null>} options.shellRef ref holding the .rd-shell root el
|
||||
* @param {object=} options.bus selection bus; when given, listens bus.on('resetLayout', reset)
|
||||
* @param {import('vue').Ref<boolean>|boolean=} options.isNarrow desktop gate (true = mobile/narrow)
|
||||
* @param {import('vue').Ref<boolean>|boolean=} options.pageScroll when true, the table axis drives
|
||||
* --rd-map-h (px) instead of --table-h — see the PAGE-SCROLL AXIS SWITCH note below.
|
||||
* @param {string=} options.storageKey localStorage key (default 'rd-dashboard-layout')
|
||||
* @param {{facetW?:number, railW?:number, tableH?:string, mapH?:number, shellH?:number}=} options.defaults default track sizes
|
||||
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number], mapH?:[number,number], shellH?:[number,number]}=} options.clamps
|
||||
* min/max — facetW/railW/mapH/shellH in px, tableH in percent-of-shell-height
|
||||
*
|
||||
* @returns {{
|
||||
* facetW: import('vue').Ref<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
|
||||
* mapH: import('vue').Ref<number>, shellH: import('vue').Ref<number>,
|
||||
* startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void,
|
||||
* startTableResize:(e:PointerEvent)=>void, startShellResize:(e:PointerEvent)=>void,
|
||||
* reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>,
|
||||
* stepResize:(axis:'facet'|'rail'|'table'|'shell', dir:-1|1, toExtreme?:boolean)=>void,
|
||||
* onResizeKeydown:(axis:'facet'|'rail'|'table'|'shell', e:KeyboardEvent)=>void
|
||||
* }}
|
||||
*/
|
||||
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
// The single key every dashboard shared before landing 2 namespaced them. Kept ONLY so a reader's
|
||||
// existing layout survives the change; nothing writes to it. Delete it once no reader can plausibly
|
||||
// still be carrying one.
|
||||
const LEGACY_SHARED_KEY = 'rd-dashboard-layout'
|
||||
|
||||
// Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue.
|
||||
// These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them.
|
||||
// mapH mirrors the CSS fallback 40svh (~360px at a 900px viewport — see the page-scroll note
|
||||
// below); it is only ever WRITTEN once the user drags, so an untouched embed stays on the
|
||||
// viewport-relative svh default and keeps resizing with the window, exactly like every other var.
|
||||
// ★ 58svh (522px) was the ORIGINAL default and left the page-scroll band taller than one desktop
|
||||
// viewport (BUG-DASHBOARD-BAND-GROWS-ENORMOUS: chrome 137px + map 522px + table 552px = 1211px
|
||||
// against 900px). 40svh is the value that makes chrome + map(X) + table(X + bar-height) fit <=900px.
|
||||
// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table
|
||||
// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split;
|
||||
// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above).
|
||||
// ★ shellH 720 -> 602 so the table OPENS at seven data rows (Driver 2026-09-23: "the table needs to
|
||||
// only show SEVEN rows by DEFAULT height"). 602 = map 360 + bar 32 + 7 rows x 26 + foot 28.
|
||||
// ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped
|
||||
// the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes
|
||||
// --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not
|
||||
// be reached by any gesture. The Driver lost BOTH resizes at once and said so: "I didn't want the
|
||||
// table set to 7. Only the table needs to be resizable. We've set it to where I can't even resize."
|
||||
// Setting the STARTING VALUE instead leaves the derivation intact: the keyhole stays
|
||||
// shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is
|
||||
// merely where it opens.
|
||||
// ★ RECLAIM PASS 2 (Driver 2026-09-23: "it is a priority to reclaim height... so I can see half
|
||||
// of the first row of the people groups grid"). mapH 360 -> 300 and shellH follows the same
|
||||
// arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table
|
||||
// promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING
|
||||
// MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a
|
||||
// shorter map shows MORE world, not less.
|
||||
// shellH 502 -> 542 (Driver 2026-09-23: "the dashboard should be taller by about 40px"). The
|
||||
// extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot);
|
||||
// the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in
|
||||
// research-dashboard.vue kept byte-equal at 542.
|
||||
// mapH 260 -> 300 (Driver 2026-09-23 follow-up: the +40px was to be "added to the map -- map is
|
||||
// 40px taller display room"). shellH stays 542, so the table's derived share returns to its
|
||||
// pre-+40 height and the whole gain lands in the map row.
|
||||
// +40px again (Driver 2026-09-23 second raise: "go taller with the dashboard by another 40px,
|
||||
// add that to the height of the map"): shellH 542 -> 582 and mapH 300 -> 340 move together, so
|
||||
// the whole gain lands in the map row and the table's derived share stays put.
|
||||
// Driver fine-tune 2026-09-23: "almost perfect height -- keep the map the same height, but
|
||||
// decrease the height of the dashboard by about 20px". Map stays 340; the 20px comes out of
|
||||
// the table's derived share (shell - map - bar - foot).
|
||||
const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 340, shellH: 562 }
|
||||
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] }
|
||||
|
||||
// Mirrors the CSS fallback --rd-table-bar-h (research-dashboard.vue's .rd-table-bar budget calc) —
|
||||
// the table's header bar (count + Export CSV) sits ABOVE its scrollport and is not itself
|
||||
// resizable, so the map/table split must always leave it at least this much room, or the divider
|
||||
// could drag the table's usable grid to zero while its outer cell still looks non-empty.
|
||||
const TABLE_BAR_H = 32
|
||||
const TABLE_MIN_H = 120 // grid's own floor below the bar — a table smaller than this is unusable
|
||||
|
||||
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v))
|
||||
const pctNum = (v, fallback) => {
|
||||
const n = parseFloat(String(v))
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export function useDashboardLayout(options = {}) {
|
||||
const {
|
||||
shellRef,
|
||||
bus = null,
|
||||
isNarrow = false,
|
||||
pageScroll = false,
|
||||
// ★ KEY VERSIONED v2 (2026-09-23). The Driver reported "sidebars are not equally spaced" on
|
||||
// his machine AFTER the symmetric-rail change landed -- because his browser restores a layout
|
||||
// SAVED under the old defaults (rail 264), and a restored value outranks any new default by
|
||||
// design. When defaults change materially, the old saved layouts are prescriptions from a
|
||||
// different bottle: bump the key so every reader starts from the new defaults once, and a
|
||||
// fresh drag re-personalises from there. The v1 blob is left in place, inert.
|
||||
// v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted
|
||||
// shellH:502 from the previous default would silently mask this change on the Driver's browser.
|
||||
// ★ NAMESPACED PER DASHBOARD, AND THAT IS THE ONE BEHAVIOUR CHANGE IN THIS MOVE.
|
||||
// SPEC-DASHBOARD-KIT section 8 named it as landing 2's risk: this key was a single constant, so
|
||||
// the moment a page carries TWO dashboards they read and write one another's layout. Dragging a
|
||||
// divider in one would move the other's on next load, and neither would be wrong on its own.
|
||||
// While there was exactly one dashboard that was invisible; sharing the engine is precisely
|
||||
// what makes a second one possible, so the fix belongs in the commit that shares it rather than
|
||||
// in the commit that first trips over it.
|
||||
// The caller passes its own id (the shell uses config.id). The default keeps a caller that
|
||||
// passes nothing working, and still namespaces it away from any caller that does.
|
||||
storageKey = 'rd-dashboard-layout-v6',
|
||||
} = options
|
||||
|
||||
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }
|
||||
const clamps = { ...CLAMPS, ...(options.clamps || {}) }
|
||||
|
||||
const facetW = ref(defaults.facetW) // px
|
||||
const railW = ref(defaults.railW) // px
|
||||
const tableH = ref(defaults.tableH) // string, e.g. '34%'
|
||||
const mapH = ref(defaults.mapH) // px — only meaningful/applied in page-scroll mode
|
||||
const shellH = ref(defaults.shellH) // px — page-scroll ELEMENT total height (map+table budget)
|
||||
const customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults?
|
||||
const mapHCustomized = ref(false) // page-scroll's own inline var is written independently of
|
||||
// customized (below), since an untouched grid-mode drag
|
||||
// must never suddenly pin --rd-map-h and vice versa.
|
||||
const shellHCustomized = ref(false) // same independence, one level up: the shell handle is a
|
||||
// separate drag from the map/table split and must not fire
|
||||
// just because the OTHER page-scroll var was touched.
|
||||
|
||||
const narrow = () => !!unref(isNarrow)
|
||||
const inPageScroll = () => !!unref(pageScroll)
|
||||
|
||||
// ── what --rd-shell-h CURRENTLY RESOLVES TO (BUG-SHELL-HANDLE-SNAPS-144PX) ───────────────────
|
||||
// The shell drag must anchor on the budget the CSS is resolving RIGHT NOW, or the first frame
|
||||
// "corrects" from a wrong anchor and the user sees height they did not drag. Two sources, in
|
||||
// priority order, because the variable and the rendered size legitimately disagree:
|
||||
//
|
||||
// 1. THE COMPUTED VARIABLE, whenever it is set. --rd-shell-h is a CEILING — it feeds the
|
||||
// table grid's max-height — and a ceiling is not a floor: if the table has fewer rows than
|
||||
// the budget allows, the element renders SMALLER than the variable says. MEASURED with a
|
||||
// restored shellH of 983: the element was 1126 while map+table summed to only 743, because
|
||||
// the grid had ~240px of budget and no rows to fill it. Anchoring on that 743 snapped the
|
||||
// variable 983 -> 744 on the first frame — a -239px jump, the mirror image of the +144 one
|
||||
// this card started from. Whatever the CSS already holds is what the drag must continue
|
||||
// from, so the pointer only ever ADDS to a value that is already in effect.
|
||||
// 2. THE MEASURED map + table, only when the variable is unset. Before the first drag the CSS
|
||||
// is on its stylesheet fallback (`80svh`), which shellH.value cannot stand in for: the 720
|
||||
// default only equals 80svh at a 900px viewport, so trusting it would jump by the
|
||||
// difference at any other window height.
|
||||
//
|
||||
// Measuring map + table rather than the shell's own box is deliberate — the shell is
|
||||
// `height:auto` here and includes the chrome the variable does not budget for. Summing the two
|
||||
// regions lands on the same span calc(shell-h - map-h - bar - foot) works from, without
|
||||
// restating the chrome's height as a second number that could drift from the stylesheet.
|
||||
function currentShellBudget() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return shellH.value
|
||||
const declared = parseFloat(getComputedStyle(el).getPropertyValue('--rd-shell-h'))
|
||||
if (Number.isFinite(declared) && declared > 0) return Math.round(declared)
|
||||
const mapEl = el.querySelector('[data-region="map"]')
|
||||
const tableEl = el.querySelector('[data-region="table"]')
|
||||
if (!mapEl || !tableEl) return shellH.value
|
||||
const h = mapEl.getBoundingClientRect().height + tableEl.getBoundingClientRect().height
|
||||
// A collapsed/hidden region measures 0; fall back rather than anchoring the drag on nothing.
|
||||
return h > 0 ? Math.round(h) : shellH.value
|
||||
}
|
||||
|
||||
// ── write / clear the inline CSS custom properties on the shell root ──
|
||||
// --rd-map-h is applied independently of `customized` (grid-mode's facet/rail/table-h gate):
|
||||
// page-scroll is a different layout entirely, with its own untouched-vs-dragged state, so
|
||||
// gating it on the grid vars' flag would either apply it before any page-scroll drag ever
|
||||
// happened, or (worse) suppress a real page-scroll drag because the grid vars were untouched.
|
||||
function applyVars() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
if (customized.value) {
|
||||
el.style.setProperty('--facet-w', facetW.value + 'px')
|
||||
el.style.setProperty('--rail-w', railW.value + 'px')
|
||||
el.style.setProperty('--table-h', tableH.value)
|
||||
}
|
||||
if (mapHCustomized.value) {
|
||||
el.style.setProperty('--rd-map-h', mapH.value + 'px')
|
||||
}
|
||||
if (shellHCustomized.value) {
|
||||
el.style.setProperty('--rd-shell-h', shellH.value + 'px')
|
||||
}
|
||||
}
|
||||
function clearVars() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
el.style.removeProperty('--facet-w')
|
||||
el.style.removeProperty('--rail-w')
|
||||
el.style.removeProperty('--table-h')
|
||||
el.style.removeProperty('--rd-map-h')
|
||||
el.style.removeProperty('--rd-shell-h')
|
||||
}
|
||||
|
||||
// ── persistence ──
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
facetW: facetW.value, railW: railW.value, tableH: tableH.value, mapH: mapH.value, shellH: shellH.value,
|
||||
}))
|
||||
} catch (_) { /* storage blocked / SSR — layout still works this session */ }
|
||||
}
|
||||
function restore() {
|
||||
try {
|
||||
// ★ A ONE-TIME READ-THROUGH FROM THE OLD SHARED KEY, so namespacing does not silently reset
|
||||
// every reader's saved layout. Read-only and never written back: the new key is written on
|
||||
// the next drag-end, and the old value is simply left where it is rather than migrated and
|
||||
// deleted — a migration that also deletes is one that cannot be rolled back.
|
||||
const raw = localStorage.getItem(storageKey) || localStorage.getItem(LEGACY_SHARED_KEY)
|
||||
if (!raw) return false
|
||||
const s = JSON.parse(raw)
|
||||
if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW)
|
||||
if (Number.isFinite(s.railW)) railW.value = clamp(s.railW, clamps.railW)
|
||||
if (s.tableH != null) tableH.value = clamp(pctNum(s.tableH, pctNum(defaults.tableH, 34)), clamps.tableH) + '%'
|
||||
customized.value = true
|
||||
// shellH restores BEFORE mapH so the mapH clamp below (which bounds map to <= shellH, see
|
||||
// startResize's isTablePageScroll branch) has the right ceiling on first paint too.
|
||||
if (Number.isFinite(s.shellH)) { shellH.value = clamp(s.shellH, clamps.shellH); shellHCustomized.value = true }
|
||||
if (Number.isFinite(s.mapH)) { mapH.value = clamp(s.mapH, clamps.mapH); mapHCustomized.value = true }
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
// ── reset: clear the key + snap back to the EXACT defaults (hand control to the stylesheet) ──
|
||||
function reset() {
|
||||
try { localStorage.removeItem(storageKey) } catch (_) { /* ignore */ }
|
||||
facetW.value = defaults.facetW
|
||||
railW.value = defaults.railW
|
||||
tableH.value = defaults.tableH
|
||||
mapH.value = defaults.mapH
|
||||
shellH.value = defaults.shellH
|
||||
customized.value = false
|
||||
mapHCustomized.value = false
|
||||
shellHCustomized.value = false
|
||||
clearVars()
|
||||
}
|
||||
|
||||
// ── drag-live signalling (BUG-RESIZE-STROBE) ──────────────────────────────
|
||||
// Two channels, because two different consumers need it:
|
||||
// 1. `data-rd-resizing` on the shell root — a pure-CSS hook so transitions/animations can be
|
||||
// killed for the duration of the drag (no JS needed, works inside shadow DOM).
|
||||
// 2. bus 'layoutResizing' — lets the map panel downgrade to a cheap canvas resize while the
|
||||
// drag is live, then do the one expensive re-fit on drag end.
|
||||
// Both are no-ops when absent, so this composable stays usable without a bus.
|
||||
const resizing = ref(false)
|
||||
function beginDrag() {
|
||||
if (resizing.value) return
|
||||
resizing.value = true
|
||||
const el = shellRef && shellRef.value
|
||||
if (el) el.setAttribute('data-rd-resizing', '1')
|
||||
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', true) } catch (_) {} }
|
||||
}
|
||||
function endDrag() {
|
||||
if (!resizing.value) return
|
||||
resizing.value = false
|
||||
const el = shellRef && shellRef.value
|
||||
if (el) el.removeAttribute('data-rd-resizing')
|
||||
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', false) } catch (_) {} }
|
||||
}
|
||||
|
||||
// ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ──
|
||||
function startResize(axis, e) {
|
||||
if (narrow()) return
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
e.preventDefault()
|
||||
const rect = el.getBoundingClientRect()
|
||||
const startX = e.clientX
|
||||
const startFacet = facetW.value
|
||||
const startRail = railW.value
|
||||
const handleEl = e.currentTarget
|
||||
// ── PAGE-SCROLL AXIS SWITCH (BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING) ──────────────────────
|
||||
// In page-scroll mode (research-dashboard.vue's pageScroll / dashboard-plus-grid.vue) the
|
||||
// shell is `height:auto` and grows with its content, so `rect` above is NOT one screen tall
|
||||
// — `rect.bottom` can sit thousands of px below the viewport. --table-h is force-set to
|
||||
// `auto` by the .rd-page-scroll stylesheet rule and no longer drives ANY layout: the map's
|
||||
// height instead comes from --rd-map-h (default 40svh as of BUG-DASHBOARD-BAND-GROWS-ENORMOUS;
|
||||
// see the CSS note beside it), and the table's own max-height comes from --rd-table-max-h.
|
||||
// Writing tableH in this mode is a complete no-op — MEASURED (at the prior 58svh default):
|
||||
// map height 522px before and after a real 200px drag, every
|
||||
// frame. The fix is to drive --rd-map-h directly, from the MAP ELEMENT's own top edge
|
||||
// (not the unbounded shell rect), so growing the map is a plain function of where the
|
||||
// pointer is relative to where the map starts.
|
||||
//
|
||||
// ★ THE map/table SPLIT vs. THE shell's OWN HEIGHT (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER)
|
||||
// — this handle must move height BETWEEN map and table, never grow the total. mapH is now
|
||||
// clamped to leave the table at least TABLE_BAR_H + TABLE_MIN_H of the FIXED shellH budget,
|
||||
// so dragging this divider can only redistribute the two, exactly like resizing an internal
|
||||
// pane split rather than the window itself.
|
||||
const isTablePageScroll = axis === 'table' && inPageScroll()
|
||||
const isShellResize = axis === 'shell' && inPageScroll()
|
||||
let startMapTop = 0
|
||||
let mapHCeiling = clamps.mapH[1]
|
||||
if (isTablePageScroll) {
|
||||
const mapEl = el.querySelector('[data-region="map"]')
|
||||
const mapRect = mapEl ? mapEl.getBoundingClientRect() : null
|
||||
startMapTop = mapRect ? mapRect.top : (e.clientY - mapH.value)
|
||||
mapHCeiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H))
|
||||
}
|
||||
// ★ DELTA-ANCHORED ON THE LIVE BUDGET (BUG-SHELL-HANDLE-SNAPS-144PX-ON-FIRST-FRAME).
|
||||
// MEASURED at 1440x900: a 1px drag grew the element 144px. TWO defects stacked here, and
|
||||
// fixing only the first leaves the number bit-for-bit unchanged — which is the clue that
|
||||
// named the second.
|
||||
//
|
||||
// 1. POINTER-ABSOLUTE math. The old line set shellH to (pointerY - shellTop), treating the
|
||||
// cursor's Y AS the new height. That is only correct if the handle sits exactly shellH
|
||||
// px below the shell's top, and it does not. Fixed by anchoring on a start value and
|
||||
// adding the pointer DELTA.
|
||||
// 2. THE WRONG ANCHOR — the actual source of the 144. Anchoring on the element's measured
|
||||
// height is the obvious move and is wrong, because --rd-shell-h IS NOT THE ELEMENT'S
|
||||
// HEIGHT. The shell is `height:auto` in page-scroll mode (.rd-shell.rd-page-scroll) and
|
||||
// nothing reads --rd-shell-h as a height; its ONLY consumer is the table grid's
|
||||
// max-height: calc(--rd-shell-h - --rd-map-h - bar - foot). So the variable budgets
|
||||
// MAP + TABLE and excludes the chrome above them (topbar 52 + filters 55 + stats 30 =
|
||||
// 141px measured). Seeding it with the measured 863 published a budget 144px too large:
|
||||
// the grid's max-height jumped 300 -> 444 on the FIRST frame and the element grew with
|
||||
// it. 863 - 719 = 144 is not a coincidence — it is the chrome, double-counted.
|
||||
const startShellY = e.clientY
|
||||
const startShellH = isShellResize ? currentShellBudget() : shellH.value
|
||||
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
|
||||
|
||||
// ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ──
|
||||
// Writing the CSS vars every pointermove reflows the grid, which fires the map panel's
|
||||
// ResizeObserver → map.resize() → MapLibre's own 'resize' event → useMapInstance's
|
||||
// _onResize → setMinZoom/setZoom. Because the zoom FLOOR is derived from the container
|
||||
// HEIGHT, a vertical drag re-snapped the camera on EVERY frame — a ~60Hz full-canvas
|
||||
// re-render that reads as strobing. Flagging the drag lets the map coalesce to a single
|
||||
// cheap canvas resize while dragging and do the one expensive re-fit on drag END.
|
||||
beginDrag()
|
||||
|
||||
// Coalesce the inline-var writes to one per animation frame. pointermove can fire faster
|
||||
// than the compositor on high-polling-rate mice, so without this the grid could reflow
|
||||
// several times per painted frame — wasted work that makes the strobe worse.
|
||||
let frame = 0
|
||||
let pending = null
|
||||
function flush() {
|
||||
frame = 0
|
||||
if (!pending) return
|
||||
const ev = pending
|
||||
pending = null
|
||||
if (axis === 'facet') {
|
||||
// left facet edge → dragging right widens the facet column
|
||||
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
|
||||
customized.value = true
|
||||
} else if (axis === 'rail') {
|
||||
// right rail's LEFT edge → dragging left widens the rail column
|
||||
railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW))
|
||||
customized.value = true
|
||||
} else if (isTablePageScroll) {
|
||||
// page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is
|
||||
// align-self:end on the map grid-area — see the CSS below), whose top never moves. The
|
||||
// pointer's new Y position IS the map's new bottom edge, so the new height is simply the
|
||||
// distance from the map's (fixed) top to the cursor — a direct 1:1 follow, exactly like
|
||||
// dragging the corner of a window. Re-derived fresh every frame from startMapTop (not
|
||||
// summed from a delta), so it can never drift from the cursor across a long drag.
|
||||
// Clamped to mapHCeiling (not the raw clamps.mapH max) — the ceiling reserves the table's
|
||||
// bar + a usable minimum out of the FIXED shellH budget, so this drag only ever moves
|
||||
// height between map and table and can never grow the element's total height. That is
|
||||
// the shell axis's job (see isShellResize below), not this one's.
|
||||
mapH.value = Math.round(clamp(ev.clientY - startMapTop, [clamps.mapH[0], mapHCeiling]))
|
||||
mapHCustomized.value = true
|
||||
} else if (axis === 'table') {
|
||||
// map/table divider → dragging up grows the table (height measured from shell bottom)
|
||||
const pct = ((rect.bottom - ev.clientY) / rect.height) * 100
|
||||
tableH.value = Math.round(clamp(pct, clamps.tableH)) + '%'
|
||||
customized.value = true
|
||||
} else if (isShellResize) {
|
||||
// The NEW shell handle — spans the whole element's bottom edge (research-dashboard.vue's
|
||||
// .rd-rz-shell), the boundary with whatever a host stacks below (e.g. the people-groups
|
||||
// grid in dashboard-plus-grid.vue). Same fixed-top / 1:1-follow pattern as the map handle
|
||||
// above. This is the ONLY axis that changes --rd-shell-h, i.e. the only handle that
|
||||
// changes the element's total height — the map/table divider above must never do this
|
||||
// (see its own comment).
|
||||
// DELTA from the live start budget, NOT the pointer's absolute Y: unlike the map handle,
|
||||
// this variable is not a distance from a fixed top edge, so there is no "pointer Y IS the
|
||||
// new value" identity to exploit — assuming one produced the 144px snap (see the note on
|
||||
// startShellH above).
|
||||
shellH.value = Math.round(clamp(startShellH + (ev.clientY - startShellY), clamps.shellH))
|
||||
shellHCustomized.value = true
|
||||
}
|
||||
applyVars()
|
||||
}
|
||||
function move(ev) {
|
||||
// keep only the LATEST position; the rAF does the single write for this frame
|
||||
pending = { clientX: ev.clientX, clientY: ev.clientY }
|
||||
if (!frame) frame = requestAnimationFrame(flush)
|
||||
}
|
||||
function up(ev) {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
if (frame) { cancelAnimationFrame(frame); frame = 0 }
|
||||
flush() // land the final position even if the last frame never ran
|
||||
try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {}
|
||||
persist()
|
||||
endDrag()
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
}
|
||||
|
||||
const startFacetResize = (e) => startResize('facet', e)
|
||||
const startRailResize = (e) => startResize('rail', e)
|
||||
const startTableResize = (e) => startResize('table', e)
|
||||
const startShellResize = (e) => startResize('shell', e)
|
||||
|
||||
// ── keyboard step-resize (a11y: role="separator" needs an operable equivalent to the drag) ──
|
||||
// Reuses the SAME refs/clamps/persist/applyVars as the pointer path — no parallel math. One
|
||||
// discrete step per keypress (no rAF coalescing needed; a keydown can't fire faster than a frame).
|
||||
// `table` axis mirrors startResize's own page-scroll branch: in page-scroll mode it steps mapH
|
||||
// (the map/table split within the FIXED shellH budget), otherwise it steps the grid-mode tableH
|
||||
// percentage. `shell` steps shellH directly — the only axis that changes the element's total height.
|
||||
const STEP = { facet: 16, rail: 16, table: 24, mapH: 24, shellH: 24 } // px per arrow-key press (tableH in %)
|
||||
// `toExtreme` bypasses the per-step delta entirely and clamps straight to lo/hi — Home/End jump,
|
||||
// not a long walk of individual steps (which would also mean 200 localStorage writes per keypress).
|
||||
function stepResize(axis, dir, toExtreme) {
|
||||
if (narrow()) return
|
||||
if (axis === 'facet') {
|
||||
facetW.value = toExtreme ? (dir < 0 ? clamps.facetW[0] : clamps.facetW[1])
|
||||
: Math.round(clamp(facetW.value + dir * STEP.facet, clamps.facetW))
|
||||
customized.value = true
|
||||
} else if (axis === 'rail') {
|
||||
// Same "left edge, dragging left widens" sense as the pointer path: ArrowLeft (dir -1)
|
||||
// widens the rail, so the rail's step direction is inverted relative to facet's.
|
||||
railW.value = toExtreme ? (dir < 0 ? clamps.railW[1] : clamps.railW[0])
|
||||
: Math.round(clamp(railW.value - dir * STEP.rail, clamps.railW))
|
||||
customized.value = true
|
||||
} else if (axis === 'table' && inPageScroll()) {
|
||||
// Same ceiling as the pointer path (startResize's mapHCeiling): reserve the table's bar +
|
||||
// usable minimum out of the CURRENT shellH so a keyboard drag can redistribute but never
|
||||
// grow the total either.
|
||||
const ceiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H))
|
||||
mapH.value = toExtreme ? (dir < 0 ? clamps.mapH[0] : ceiling)
|
||||
: Math.round(clamp(mapH.value + dir * STEP.mapH, [clamps.mapH[0], ceiling]))
|
||||
mapHCustomized.value = true
|
||||
} else if (axis === 'table') {
|
||||
const pct = toExtreme ? (dir < 0 ? clamps.tableH[0] : clamps.tableH[1])
|
||||
: clamp(pctNum(tableH.value, pctNum(defaults.tableH, 34)) - dir * STEP.table / 4, clamps.tableH)
|
||||
tableH.value = Math.round(pct) + '%'
|
||||
customized.value = true
|
||||
} else if (axis === 'shell' && inPageScroll()) {
|
||||
shellH.value = toExtreme ? (dir < 0 ? clamps.shellH[0] : clamps.shellH[1])
|
||||
: Math.round(clamp(shellH.value + dir * STEP.shellH, clamps.shellH))
|
||||
shellHCustomized.value = true
|
||||
}
|
||||
applyVars()
|
||||
persist()
|
||||
}
|
||||
// ArrowUp/Left = grow toward the start edge, ArrowDown/Right = grow toward the end edge, matching
|
||||
// each handle's drag SENSE (facet/rail are col-resize, table/map/shell are row-resize). Home/End
|
||||
// jump to the axis's clamp extremes in one write (not a walk of individual steps).
|
||||
function onResizeKeydown(axis, e) {
|
||||
const K = e.key
|
||||
let dir = 0
|
||||
if (axis === 'table' || axis === 'shell') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 }
|
||||
else { if (K === 'ArrowLeft') dir = -1; else if (K === 'ArrowRight') dir = 1 }
|
||||
if (dir) { e.preventDefault(); stepResize(axis, dir); return }
|
||||
if (K === 'Home' || K === 'End') {
|
||||
e.preventDefault()
|
||||
stepResize(axis, K === 'Home' ? -1 : 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ── lifecycle: restore + apply after the shell el exists; wire the reset bus event ──
|
||||
let offReset = null
|
||||
onMounted(() => {
|
||||
if (restore()) applyVars()
|
||||
if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
try { offReset && offReset() } catch (_) {}
|
||||
// If the component tears down mid-drag, release the suppression so a re-mounted map
|
||||
// is never left permanently in the cheap "resizing" mode.
|
||||
try { endDrag() } catch (_) {}
|
||||
})
|
||||
|
||||
return {
|
||||
facetW, railW, tableH, mapH, shellH, customized, resizing,
|
||||
startFacetResize, startRailResize, startTableResize, startShellResize,
|
||||
startResize, reset, applyVars, stepResize, onResizeKeydown,
|
||||
}
|
||||
}
|
||||
|
|
@ -106,6 +106,36 @@ export function isMapboxMap(map) {
|
|||
* @param {object} map the live map instance
|
||||
* @returns {object|undefined} the engine namespace, or undefined when neither is loaded
|
||||
*/
|
||||
/**
|
||||
* The provider NAME that built THIS map: 'mapbox', 'maplibre', or undefined when neither global is
|
||||
* loaded or `map` is not a map at all.
|
||||
*
|
||||
* ★ WHY THIS EXISTS, given engineFor() is right above it. engineFor returns the engine NAMESPACE,
|
||||
* which is what you want for constructing things (Marker, Popup, a second Map). Two call sites want
|
||||
* the opposite: a NAME, to put in a diagnostic field and to choose an attribution string. They were
|
||||
* each doing their own `map instanceof window.maplibregl.Map` sniff, which is the raw-global coupling
|
||||
* C3 exists to remove — and `resolveMapProvider()` cannot serve them because it reads a CONFIG, not a
|
||||
* live map, so on a dual-engine page it answers about the wrong thing.
|
||||
*
|
||||
* ★ THE AMBIGUOUS CASE IS THE WHOLE REASON THIS RETURNS undefined RATHER THAN A DEFAULT. Both call
|
||||
* sites tested for MAPLIBRE and fell through to 'mapbox', so a stub, a destroyed map or a page where
|
||||
* neither global loaded read as 'mapbox'. A rewrite in terms of the existing isMapboxMap() would have
|
||||
* inverted that — falling through to 'maplibre' — and silently changed what a diagnostic reports and
|
||||
* which attribution a poster prints. Returning undefined forces each caller to keep its OWN default
|
||||
* visible at the call site, so the migration cannot quietly move a default. Changing a default is not
|
||||
* a curation migration.
|
||||
*
|
||||
* @param {object} map the live map instance
|
||||
* @returns {'mapbox'|'maplibre'|undefined}
|
||||
*/
|
||||
export function providerFor(map) {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const ml = window.maplibregl, mb = window.mapboxgl
|
||||
if (ml && ml.Map && map instanceof ml.Map) return 'maplibre'
|
||||
if (mb && mb.Map && map instanceof mb.Map) return 'mapbox'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function engineFor(map) {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const ml = window.maplibregl, mb = window.mapboxgl
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
/**
|
||||
* useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus (RDASH phase 1 stub).
|
||||
* useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus.
|
||||
*
|
||||
* ★ SHARED AS OF DASHBOARD-KIT LANDING 1. This is the first piece of Big Picture to become
|
||||
* library code, and it went first for one reason: it has ZERO domain references and imports
|
||||
* nothing but `vue`, so it is the only piece that could move with no adapter and no behaviour
|
||||
* change. Everything below is the file as research-dashboard shipped it — not a rewrite, not a
|
||||
* generalisation. A move that also edits is a move nobody can verify.
|
||||
*
|
||||
* research-dashboard's old copy re-exported this file for one cycle so nothing broke mid-flight;
|
||||
* that shim is now deleted. See _qa-cycle/03-deployment-cycle/SPEC-DASHBOARD-KIT.md sections 5.2 and 8.
|
||||
*
|
||||
* The move and SPEC-DASHBOARD-KIT D1 (multi-value `filterSet`: an array means OR-within-key,
|
||||
* AND-across-keys) landed as separate commits, the move first, so 'Big Picture is unchanged' by the
|
||||
* move stayed provable rather than asserted.
|
||||
*
|
||||
* Two DISTINCT global reactive objects, two distinct visual languages (plan §INTERACTION):
|
||||
* - filterSet : active facet predicates (tree node / map region / chart slice).
|
||||
|
|
@ -119,6 +132,15 @@ export function createSelectionBus() {
|
|||
* per cleared tier (same batching discipline as commitSelection).
|
||||
*/
|
||||
function setFilter(key, value) {
|
||||
// ★ AN ARRAY MEANS OR-WITHIN-KEY (SPEC-DASHBOARD-KIT D1). An EMPTY array is not a filter that
|
||||
// matches nothing — it is no filter, and it clears like null. Treating [] as "narrow to zero"
|
||||
// is the same silent-empty-screen failure the hierarchy contract above exists to prevent, and
|
||||
// a UI that deselects its last chip produces exactly that array.
|
||||
if (Array.isArray(value) && value.length === 0) value = null
|
||||
// A one-element array is stored as the SCALAR it means. The hash, the chips and every adapter
|
||||
// then keep their existing single-value shape for the overwhelmingly common case, so D1 costs
|
||||
// nothing to read until somebody actually picks two.
|
||||
if (Array.isArray(value) && value.length === 1) value = value[0]
|
||||
if (value === null || value === undefined) {
|
||||
delete filterSet[key]
|
||||
// transitive: childFacets[key] already holds the full descendant closure
|
||||
|
|
@ -201,5 +223,55 @@ export function createSelectionBus() {
|
|||
filterSet, selectionSet, selectionVersion, searchTerm, on, emit,
|
||||
setFilter, clearFilters, setSearch, configureHierarchy,
|
||||
toggleSelect, clearSelection, selectMany, deselectMany, setSelection,
|
||||
toggleFilterValue, facetMatches, isFilterActive, rowMatchesFilters,
|
||||
}
|
||||
|
||||
// ── THE PREDICATE, AND WHY IT LIVES HERE ───────────────────────────────────────────────────
|
||||
// `a(r) === filterSet[k]` was written out SEVEN times: the shell's filteredRows, the facet
|
||||
// rail twice, the map panel, the table, the charts rail and the CSV exporter. Seven copies of
|
||||
// one rule is survivable while the rule is one character long. It stops being survivable the
|
||||
// moment the rule grows an array case — six of the seven would keep comparing an array with
|
||||
// `===`, every one of them would quietly match nothing, and the six panels would disagree with
|
||||
// each other about what is on screen. That is not a hypothetical: the facet rail computes the
|
||||
// COUNTS the user reads before clicking, so a rail that still used `===` would print counts for
|
||||
// a scope the map and table no longer show.
|
||||
//
|
||||
// So D1 is not only "filterSet may hold an array". It is "there is one function that answers
|
||||
// does this row match, and every panel calls it". The array support is four lines; the
|
||||
// de-duplication is the part that makes it safe.
|
||||
function facetMatches(rowValue, selected) {
|
||||
if (selected === null || selected === undefined) return true
|
||||
// OR within the key. Compared with ===, exactly as the scalar path always has been: the bus
|
||||
// stores whatever the facet accessor produced, and a looser comparison here would silently
|
||||
// widen every existing filter.
|
||||
if (Array.isArray(selected)) return selected.some((v) => rowValue === v)
|
||||
return rowValue === selected
|
||||
}
|
||||
/** Does `row` survive every active facet? `ACC` is facetKey -> accessor (the shell's FACET_ACC).
|
||||
* A key with no accessor is skipped, not failed — a facet declared for the rail but not for the
|
||||
* data must not empty the board. */
|
||||
function rowMatchesFilters(row, ACC) {
|
||||
for (const k of Object.keys(filterSet)) {
|
||||
const a = ACC && ACC[k]
|
||||
if (a && !facetMatches(a(row), filterSet[k])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
/** Is `value` part of the current selection for `key`? The UI's "is this row lit" question,
|
||||
* which was `filterSet[facet] === value` in four places and has the same array problem. */
|
||||
function isFilterActive(key, value) {
|
||||
const cur = filterSet[key]
|
||||
if (cur === null || cur === undefined) return false
|
||||
return Array.isArray(cur) ? cur.includes(value) : cur === value
|
||||
}
|
||||
/** Add or remove ONE value from a key's selection — the multi-select primitive a rail row or a
|
||||
* chart slice needs. Removing the last value clears the key (and cascades), so there is no way
|
||||
* to arrive at an empty array through the UI. */
|
||||
function toggleFilterValue(key, value) {
|
||||
const cur = filterSet[key]
|
||||
if (cur === null || cur === undefined) return setFilter(key, value)
|
||||
const list = Array.isArray(cur) ? cur : [cur]
|
||||
const next = list.includes(value) ? list.filter((v) => v !== value) : list.concat(value)
|
||||
setFilter(key, next.length ? next : null)
|
||||
}
|
||||
}
|
||||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "كل نقطة هي شعب واحد من شعوب دوكسا ولغته؛ أما لون البلد فهو عدد تلك الشعوب.",
|
||||
"noneFoundShort": "«لا شيء» تعني غير موجود في المصادر التي بحثنا فيها — وليست دليلًا على عدم وجوده.",
|
||||
"tapCountry": "اضغط على بلد لترى الموارد الفعلية، شعبًا شعبًا."
|
||||
"tapCountry": "اضغط على بلد لترى الموارد الفعلية، شعبًا شعبًا.",
|
||||
"howNumbersMade": "كيف تُحسب هذه الأرقام",
|
||||
"isSignLanguage": "هذه لغة إشارة."
|
||||
},
|
||||
"tables": {
|
||||
"title": "موارد الإنجيل — البيانات",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم <strong>باسم شعبهم</strong>.",
|
||||
"loadingNamed": "جارٍ تحميل {name}…",
|
||||
"couldNotLoadNamed": "تعذّر تحميل {name}.",
|
||||
"engineMissing": "لم يتم تحميل محرك الخرائط {engine}.",
|
||||
"packetFailed": "فشل إنشاء الكُتيّب: {reason}",
|
||||
"selectionPacketFailed": "فشل إنشاء كُتيّب التحديد: {reason}",
|
||||
"posterOneTerritory": "الملصق يطبع منطقة واحدة في كل مرة — {count} محددة.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "Ein Punkt ist EINE Volksgruppe von Doxa mit ihrer eigenen Sprache; ein Land ist die ANZAHL dieser Gruppen.",
|
||||
"noneFoundShort": "„Nichts gefunden“ heißt: nicht in den geprüften Quellen — nie ein Beweis, dass es nichts gibt.",
|
||||
"tapCountry": "Tippen Sie auf ein Land, um die Ressourcen Gruppe für Gruppe zu sehen."
|
||||
"tapCountry": "Tippen Sie auf ein Land, um die Ressourcen Gruppe für Gruppe zu sehen.",
|
||||
"howNumbersMade": "Wie diese Zahlen entstehen",
|
||||
"isSignLanguage": "Dies ist eine Gebärdensprache."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Evangeliums-Ressourcen — Daten",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie <strong>mit dem Namen ihrer Volksgruppe</strong> beten.",
|
||||
"loadingNamed": "{name} wird geladen…",
|
||||
"couldNotLoadNamed": "{name} konnte nicht geladen werden.",
|
||||
"engineMissing": "Die Karten-Engine {engine} wurde nicht geladen.",
|
||||
"packetFailed": "Heft fehlgeschlagen: {reason}",
|
||||
"selectionPacketFailed": "Heft der Auswahl fehlgeschlagen: {reason}",
|
||||
"posterOneTerritory": "Das Plakat druckt ein Gebiet auf einmal — {count} ausgewählt.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "A dot is ONE of Doxa's people groups and its own language; a country is a COUNT of those groups.",
|
||||
"noneFoundShort": "“None found” means not present in the pools this build checked — never proof that nothing exists.",
|
||||
"tapCountry": "Tap a country to see the actual resources, group by group."
|
||||
"tapCountry": "Tap a country to see the actual resources, group by group.",
|
||||
"howNumbersMade": "How these numbers are made",
|
||||
"isSignLanguage": "This is a sign language."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Gospel Resources — Data",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them <strong>by their people group name</strong>.",
|
||||
"loadingNamed": "Loading {name}…",
|
||||
"couldNotLoadNamed": "Could not load {name}.",
|
||||
"engineMissing": "The {engine} map engine did not load.",
|
||||
"packetFailed": "Packet failed: {reason}",
|
||||
"selectionPacketFailed": "Selection packet failed: {reason}",
|
||||
"posterOneTerritory": "Poster prints one territory at a time — {count} selected.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "Cada punto es UN pueblo de Doxa y su propio idioma; un país es el NÚMERO de esos pueblos.",
|
||||
"noneFoundShort": "«Nada encontrado» significa ausente de las fuentes consultadas, nunca prueba de que no exista.",
|
||||
"tapCountry": "Toca un país para ver los recursos, pueblo por pueblo."
|
||||
"tapCountry": "Toca un país para ver los recursos, pueblo por pueblo.",
|
||||
"howNumbersMade": "Cómo se obtienen estas cifras",
|
||||
"isSignLanguage": "Esta es una lengua de señas."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Recursos del evangelio — Datos",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos <strong>por el nombre de su pueblo</strong>.",
|
||||
"loadingNamed": "Cargando {name}…",
|
||||
"couldNotLoadNamed": "No se pudo cargar {name}.",
|
||||
"engineMissing": "El motor de mapas {engine} no se cargó.",
|
||||
"packetFailed": "Error al generar el cuadernillo: {reason}",
|
||||
"selectionPacketFailed": "Error al generar el cuadernillo de la selección: {reason}",
|
||||
"posterOneTerritory": "El cartel imprime un territorio a la vez: {count} seleccionados.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "Un point est UN peuple de Doxa et sa propre langue ; un pays est le NOMBRE de ces peuples.",
|
||||
"noneFoundShort": "« Rien trouvé » signifie absent des sources consultées, jamais la preuve que rien n'existe.",
|
||||
"tapCountry": "Touchez un pays pour voir les ressources, peuple par peuple."
|
||||
"tapCountry": "Touchez un pays pour voir les ressources, peuple par peuple.",
|
||||
"howNumbersMade": "Comment ces chiffres sont établis",
|
||||
"isSignLanguage": "Il s’agit d’une langue des signes."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Ressources de l'Évangile — Données",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux <strong>par le nom de leur peuple</strong>.",
|
||||
"loadingNamed": "Chargement de {name}…",
|
||||
"couldNotLoadNamed": "Impossible de charger {name}.",
|
||||
"engineMissing": "Le moteur de carte {engine} ne s’est pas chargé.",
|
||||
"packetFailed": "Échec du livret : {reason}",
|
||||
"selectionPacketFailed": "Échec du livret de la sélection : {reason}",
|
||||
"posterOneTerritory": "L’affiche imprime un territoire à la fois — {count} sélectionnés.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "हर बिंदु दोक्सा का एक जनसमूह और उसकी अपनी भाषा है; देश का रंग उन जनसमूहों की संख्या है।",
|
||||
"noneFoundShort": "“कुछ नहीं मिला” का अर्थ है जाँचे गए स्रोतों में नहीं — यह प्रमाण नहीं कि कुछ है ही नहीं।",
|
||||
"tapCountry": "संसाधन देखने के लिए किसी देश पर टैप करें, जनसमूह दर जनसमूह।"
|
||||
"tapCountry": "संसाधन देखने के लिए किसी देश पर टैप करें, जनसमूह दर जनसमूह।",
|
||||
"howNumbersMade": "ये आँकड़े कैसे बनते हैं",
|
||||
"isSignLanguage": "यह एक सांकेतिक भाषा है।"
|
||||
},
|
||||
"tables": {
|
||||
"title": "सुसमाचार संसाधन — डेटा",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए <strong>उनके जनसमूह के नाम से</strong> प्रार्थना करते हुए।",
|
||||
"loadingNamed": "{name} लोड हो रहा है…",
|
||||
"couldNotLoadNamed": "{name} लोड नहीं हो सका।",
|
||||
"engineMissing": "{engine} नक्शा इंजन लोड नहीं हुआ।",
|
||||
"packetFailed": "पुस्तिका विफल: {reason}",
|
||||
"selectionPacketFailed": "चयन की पुस्तिका विफल: {reason}",
|
||||
"posterOneTerritory": "पोस्टर एक समय में एक क्षेत्र छापता है — {count} चयनित।",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "Ogni punto è UN popolo di Doxa con la sua lingua; un paese è il NUMERO di quei popoli.",
|
||||
"noneFoundShort": "«Nulla trovato» significa assente dalle fonti controllate, mai la prova che non esista.",
|
||||
"tapCountry": "Tocca un paese per vedere le risorse, popolo per popolo."
|
||||
"tapCountry": "Tocca un paese per vedere le risorse, popolo per popolo.",
|
||||
"howNumbersMade": "Come si ottengono questi numeri",
|
||||
"isSignLanguage": "Questa è una lingua dei segni."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Risorse del Vangelo — Dati",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Distribuisci il compito della preghiera in gruppi di cinquanta e di cento — come Gesù sfamò i 5.000 (Luca 9:14). Ogni poster è una lista di 100 che puoi pregare, stampare e appendere al muro — pregando per loro <strong>con il nome del loro popolo</strong>.",
|
||||
"loadingNamed": "Caricamento di {name}…",
|
||||
"couldNotLoadNamed": "Impossibile caricare {name}.",
|
||||
"engineMissing": "Il motore di mappa {engine} non è stato caricato.",
|
||||
"packetFailed": "Libretto non riuscito: {reason}",
|
||||
"selectionPacketFailed": "Libretto della selezione non riuscito: {reason}",
|
||||
"posterOneTerritory": "Il manifesto stampa un territorio alla volta — {count} selezionati.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "Cada ponto é UM povo da Doxa e o seu próprio idioma; um país é o NÚMERO desses povos.",
|
||||
"noneFoundShort": "«Nada encontrado» significa ausente das fontes verificadas, nunca prova de que não exista.",
|
||||
"tapCountry": "Toque num país para ver os recursos, povo a povo."
|
||||
"tapCountry": "Toque num país para ver os recursos, povo a povo.",
|
||||
"howNumbersMade": "Como estes números são obtidos",
|
||||
"isSignLanguage": "Esta é uma língua de sinais."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Recursos do evangelho — Dados",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Reparta a tarefa da oração em grupos de cinquenta e de cem — como Jesus alimentou os 5.000 (Lucas 9:14). Cada cartaz é uma lista de 100 que pode orar, imprimir e pôr na parede — orando por eles <strong>pelo nome do seu povo</strong>.",
|
||||
"loadingNamed": "Carregando {name}…",
|
||||
"couldNotLoadNamed": "Não foi possível carregar {name}.",
|
||||
"engineMissing": "O motor de mapa {engine} não foi carregado.",
|
||||
"packetFailed": "Falha ao gerar o caderno: {reason}",
|
||||
"selectionPacketFailed": "Falha ao gerar o caderno da seleção: {reason}",
|
||||
"posterOneTerritory": "O cartaz imprime um território de cada vez — {count} selecionados.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "Fiecare punct este UN popor Doxa și limba lui; o țară este NUMĂRUL acelor popoare.",
|
||||
"noneFoundShort": "„Nimic găsit” înseamnă absent din sursele verificate, niciodată dovada că nu există.",
|
||||
"tapCountry": "Atinge o țară pentru a vedea resursele, popor cu popor."
|
||||
"tapCountry": "Atinge o țară pentru a vedea resursele, popor cu popor.",
|
||||
"howNumbersMade": "Cum se obțin aceste cifre",
|
||||
"isSignLanguage": "Aceasta este o limbă a semnelor."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Resurse ale Evangheliei — Date",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Împarte sarcina rugăciunii în grupuri de câte cincizeci și o sută — așa cum Isus a hrănit cei 5.000 (Luca 9:14). Fiecare afiș este o listă de 100 pe care o poți ruga, tipări și pune pe perete — rugându-te pentru ei <strong>pe numele poporului lor</strong>.",
|
||||
"loadingNamed": "Se încarcă {name}…",
|
||||
"couldNotLoadNamed": "Nu s-a putut încărca {name}.",
|
||||
"engineMissing": "Motorul de hartă {engine} nu s-a încărcat.",
|
||||
"packetFailed": "Broșura a eșuat: {reason}",
|
||||
"selectionPacketFailed": "Broșura selecției a eșuat: {reason}",
|
||||
"posterOneTerritory": "Afișul tipărește un teritoriu pe rând — {count} selectate.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "Точка — это ОДИН народ Doxa и его родной язык; цвет страны — это ЧИСЛО таких народов.",
|
||||
"noneFoundShort": "«Ничего не найдено» значит: нет в проверенных источниках — но не доказательство отсутствия.",
|
||||
"tapCountry": "Нажмите на страну, чтобы увидеть ресурсы по каждому народу."
|
||||
"tapCountry": "Нажмите на страну, чтобы увидеть ресурсы по каждому народу.",
|
||||
"howNumbersMade": "Как получены эти цифры",
|
||||
"isSignLanguage": "Это жестовый язык."
|
||||
},
|
||||
"tables": {
|
||||
"title": "Ресурсы Евангелия — данные",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "Разделите молитвенный труд на группы по пятьдесят и по сто — так Иисус накормил 5000 (Луки 9:14). Каждый плакат — это список из 100, о котором можно молиться, распечатать и повесить на стену, молясь о них <strong>по имени их народа</strong>.",
|
||||
"loadingNamed": "Загрузка {name}…",
|
||||
"couldNotLoadNamed": "Не удалось загрузить {name}.",
|
||||
"engineMissing": "Картографический движок {engine} не загрузился.",
|
||||
"packetFailed": "Не удалось создать буклет: {reason}",
|
||||
"selectionPacketFailed": "Не удалось создать буклет выбора: {reason}",
|
||||
"posterOneTerritory": "Плакат печатает по одной территории — выбрано {count}.",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
"guide": {
|
||||
"dotVsCountry": "每个圆点代表 Doxa 的一个族群及其语言;国家的颜色代表这类族群的数量。",
|
||||
"noneFoundShort": "“一无所获”指未见于本次查询的资源库,并不证明其不存在。",
|
||||
"tapCountry": "点按国家,逐个族群查看实际资源。"
|
||||
"tapCountry": "点按国家,逐个族群查看实际资源。",
|
||||
"howNumbersMade": "这些数字是如何得出的",
|
||||
"isSignLanguage": "这是一种手语。"
|
||||
},
|
||||
"tables": {
|
||||
"title": "福音资源 — 数据",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"upg100WallIntro": "將禱告的工作分成五十人、一百人一組 — 就像耶穌餵飽五千人那樣(路加福音 9:14)。每一張海報都是一份 100 人名單,你可以為他們禱告、列印出來、貼在牆上 — <strong>按著他們族群的名字</strong>為他們禱告。",
|
||||
"loadingNamed": "正在加载{name}…",
|
||||
"couldNotLoadNamed": "无法加载{name}。",
|
||||
"engineMissing": "{engine} 地图引擎未能加载。",
|
||||
"packetFailed": "祷告册生成失败:{reason}",
|
||||
"selectionPacketFailed": "所选内容的祷告册生成失败:{reason}",
|
||||
"posterOneTerritory": "海报一次只能打印一个区域——已选 {count} 个。",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
★ WHY THIS EXISTS (the second shared layout, alongside VerticalTabsLayout). VerticalTabsLayout shows
|
||||
ONE element at a time (tabs, fixed-viewport panes). StackLayout does the OPPOSITE: it renders ALL of
|
||||
its elements STACKED VERTICALLY, each at its NATURAL height, so the whole thing grows the page and the
|
||||
HOST page scrolls through them — exactly how a normal document / a doxa-life / nuxtinator page flows.
|
||||
HOST page scrolls through them — exactly how a normal host page flows.
|
||||
Use it to GROUP two (or more) elements on top of each other and embed the group on a page you scroll.
|
||||
|
||||
★ SAME NESTED-ELEMENT CONTRACT as VerticalTabsLayout (so a shell composes other element-plugins). Pass
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
self-sizing element that still can't measure its own height in flow, pass :min-section-height (or a
|
||||
section's own `minHeight`) to reserve room. Optionally cap a section with :max-section-height to give
|
||||
a genuinely tall element an inner scroll instead of an endless page (default: no cap = pure page
|
||||
scroll — the doxa-life / nuxtinator page-flow model).
|
||||
scroll — a normal host page's flow).
|
||||
|
||||
★ EMBEDDING A LAYOUT INSIDE A LAYOUT (scroll-page in the cap-5 main). The cap-5 SPA's shell is
|
||||
VerticalTabsLayout (sidebar + a FIXED-VIEWPORT content frame whose pane is `position:absolute;
|
||||
|
|
@ -210,7 +210,7 @@ export default defineComponent({
|
|||
// Optional top-bar title (omit for a bare stack with no header).
|
||||
title: { type: String, default: '' },
|
||||
// Optional per-section max height (e.g. '70vh') → that section gets an inner scroll instead of an
|
||||
// unbounded page. Default '' = no cap, pure page scroll (the doxa-life/nuxtinator page-flow model).
|
||||
// unbounded page. Default '' = no cap, pure page scroll (a normal host page's flow).
|
||||
maxSectionHeight: { type: String, default: '' },
|
||||
// Optional default MIN height for every section (e.g. '60vh') → reserves room for a self-sizing
|
||||
// element that ships its own fixed layout and can't measure its natural height in flow. A section
|
||||
|
|
|
|||
|
|
@ -1060,7 +1060,7 @@ async function mount(id){
|
|||
// a subpath (e.g. /plugin-assets/doxa-maps-spa/), a bare '/assets' or './assets' misresolves,
|
||||
// so hand each bundle an ABSOLUTE base derived from THIS shell's own document — correct at any depth.
|
||||
if (!cfg.assetsBase) cfg.assetsBase = new URL('assets', document.baseURI).href.replace(/\/+$/, '')
|
||||
// QA corner widget ON for the go-base SPA surface (opt-in flag; off for partner embeds).
|
||||
// QA corner widget ON for our own SPA surface (opt-in flag; off for partner embeds).
|
||||
cfg.qa = (new URLSearchParams(location.search).get('qa') === '1') /* QA opt-in via ?qa=1; widget exists only in DOXA_QA=1 builds */; cfg.qaLabel = spec.label || cfg.profile
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg))
|
||||
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -75,6 +75,7 @@
|
|||
"check:facet-one-field": "node internal/check-facet-one-field.mjs",
|
||||
"check:facet-one-field:self-test": "node internal/check-facet-one-field.mjs --self-test",
|
||||
"check:filter-width": "node internal/check-filter-width-stable.mjs",
|
||||
"check:filter-multivalue": "node plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs",
|
||||
"check:fits-screen": "node internal/check-fits-the-screen.mjs",
|
||||
"check:fits-screen:self-test": "node internal/check-fits-the-screen.mjs --self-test",
|
||||
"check:fits-screen:self-test:b": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=b",
|
||||
|
|
@ -90,6 +91,8 @@
|
|||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
|
||||
"check:embed-contract": "node internal/check-gospel-embed-contract.mjs",
|
||||
"check:engine-contract": "node internal/check-gospel-engine-contract.mjs",
|
||||
"check:engine-contract:poison": "node internal/check-gospel-engine-contract.mjs --poison",
|
||||
"check:embed-contract:poison": "node internal/check-gospel-embed-contract.mjs --poison",
|
||||
"check:map-attribution": "node internal/check-map-attribution.mjs",
|
||||
"check:studio-rail": "node internal/check-studio-rail.mjs",
|
||||
|
|
@ -105,6 +108,7 @@
|
|||
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
|
||||
"check:label-joins": "node internal/check-label-joins.mjs",
|
||||
"check:language-code-link": "node plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs",
|
||||
"check:layout-key-namespaced": "node plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs",
|
||||
"check:leaf-engine-per-provider": "node internal/check-leaf-engine-per-provider.mjs",
|
||||
"check:leaf-english-leaks": "node internal/check-leaf-english-leaks.mjs",
|
||||
"check:leaf-english-leaks:self-test": "node internal/check-leaf-english-leaks.mjs --self-test",
|
||||
|
|
@ -167,6 +171,7 @@
|
|||
"check:print-geometry": "node internal/check-print-geometry.mjs",
|
||||
"check:pin-dim-slider": "node plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs",
|
||||
"check:profiles-mount": "node internal/check-composites-mount.mjs --all-profiles",
|
||||
"check:provider-for": "node internal/check-provider-for.mjs",
|
||||
"check:provider-switch": "node internal/check-provider-switch.mjs",
|
||||
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
|
||||
"check:published": "node internal/check-published-matches-build.mjs",
|
||||
|
|
@ -216,7 +221,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -306,6 +311,7 @@
|
|||
"check:facet-changes-map:built": "the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.",
|
||||
"check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.",
|
||||
"check:facet-one-field": "ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r.<field> it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.",
|
||||
"check:filter-multivalue": "SPEC-DASHBOARD-KIT D1: filterSet[key] may hold an array meaning OR-within-key, keys still AND together — and exactly ONE function answers 'does this row match'. The rule `a(r) === filterSet[k]` was written out SEVEN times (shell, facet rail x2, map panel, table, charts rail, CSV exporter); the moment it grew an array case, six copies would have kept comparing an array with === , matched nothing, and left the panels disagreeing about what is on screen — the facet rail printing counts for a scope the map and table no longer render. Runs the real bus (OR within, AND across, one-element array collapses to a scalar, EMPTY array clears rather than matching nothing, toggleFilterValue adds/removes/clears, cascade intact), greps the panels for surviving copies, round-trips the URL hash including a value containing a comma, and lifts the grid's valueMatches to prove both ends OR the same way. The list separator is `|` and not `,` because 120 of the 864 live language labels contain a comma. No browser.",
|
||||
"check:fits-screen": "THE FITNESS-FOR-USE GATE (LL-the-user-found-three-bugs-my-gates-could-not-see): a green STATE suite (mounts/fetches/counts/paints) said nothing about whether the product is USABLE, and the Driver found five real defects in one sitting that no gate saw. Asserts 5 clauses on the built dashboard-plus-grid composite + research-dashboard leaf, real browser, live data: (A) the dashboard band fits ONE 1440x900 viewport and is shorter than its own tree-legend's scrollHeight; (B) the people-groups grid is still reachable by scrolling underneath it (a fix for A must not re-break this — AP-fixing-reachability-by-making-the-thing-taller); (C) a wheel over the map changes ZOOM and does not scroll the pane, a wheel below it scrolls and does not zoom — instrumented on the DISPATCHED EVENT (composedPath), not geometry, per LL-four-wrong-diagnoses-instrument-the-event-not-the-geometry; (D) the grid's live count ([data-table-count], the composite's documented single source of truth — show-total=false on the grid child is deliberate) is visible and CHANGES when a facet narrows it; (E) a 1440/1280/768/390 responsive sweep has no horizontal document overflow and no element clipped to zero height (.rd-td/.rd-th ellipsis excluded — expected). Spawns its own serve-strict on PORT (default 4391); SKIPs (exit 0) if the tree is not settled at START OR re-settled at END, or pray.doxa.life is unreachable — never reports a defect from a torn tree. Also best-effort probes any cap-5 shell the manifest declares (type nested-mfe-spa) by CONTENT-MATCHING a tab, never by hardcoded structure, so it survives the shell's own in-flight restructure without depending on it. Needs a built tree (`bun run build`). run `check:fits-screen:self-test` for the NEGATIVE test.",
|
||||
"check:fits-screen:self-test": "FIVE regression modes, one per clause, each required to fire ITS OWN [A]/[B]/[C]/[D]/[E]-named failure: run clause a via THIS entry and b-e via `check:fits-screen:self-test:<b|c|d|e>`. a (default — forces the dashboard section past the viewport, the AP-fixing-reachability shape), b (n/a here — B is proven by A's own poison already trapping the grid below the fold; kept as a mode for a future independent poison), c (freezes map.scrollZoom enabled, the BUG-MAP-EATS-THE-WHEEL regression), d (blanks the live count element), e (forces the shell wider than the viewport at 1440). A gate that has only ever printed PASS is unproven.",
|
||||
"check:fits-screen:self-test:b": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
|
|
@ -313,7 +319,7 @@
|
|||
"check:fits-screen:self-test:d": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:fits-screen:self-test:e": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
|
||||
"check:gallery-embed": "the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare <script>+<doxa-maps-spa> and <iframe>, with the layout inside the host box and every curated tab painting a map. The summary line prints the tab count PER MODE, and a mode that checked zero tabs is a FAIL. One mode only: check:gallery-embed:js or check:gallery-embed:iframe. Starts its own servers on OS-assigned ports; needs only a built tree. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on nuxtbase): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
|
||||
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on the live test host): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
|
||||
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
|
||||
"check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
|
|
@ -322,6 +328,7 @@
|
|||
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
|
||||
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",
|
||||
"check:layout-key-namespaced": "two dashboards on one page must not share a saved layout. useDashboardLayout persists the four divider positions to localStorage, and until kit landing 2 the key was a single constant — invisible while there was exactly one dashboard, and SHARING THE ENGINE IS WHAT MAKES A SECOND ONE POSSIBLE, so SPEC-DASHBOARD-KIT section 8 named it as that landing's risk. The failure is quiet rather than loud: drag a divider in one dashboard, reload, and the other has moved. Asserts the shell passes no bare literal, EVALUATES the real expression with two different dashboard ids to prove the key varies (never a retyped copy of the rule), and that the pre-namespacing key is READ and never written — namespacing must not silently discard a reader's layout, and a migration that also deletes cannot be rolled back. Static; no DOM, no browser.",
|
||||
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
|
||||
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
|
||||
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
|
||||
|
|
@ -363,6 +370,7 @@
|
|||
"check:poster-print-fallback": "clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
|
||||
"check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT=<dir> so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.",
|
||||
"check:profiles-mount": "check:composites-mount over EVERY profile of every bundle and every preset instance (with its props), in en and ar: any setup, watcher or mount error fails it. Headless, ~40 s. A source mount: a build-only error (a minified name hoisted out of order) needs the built bundle and is not covered.",
|
||||
"check:provider-for": "providerFor(map) must answer the same as the raw `map instanceof window.maplibregl.Map` sniffs it replaced (C3) - on BOTH engines AND on the AMBIGUOUS input that is neither, where the originals fell through to mapbox/false. Carries an executable proof that the obvious isMapboxMap() rewrite INVERTS that fall-through while agreeing on both real engines, which is how it would have changed a poster attribution unnoticed. Headless, source-only. engineGlobal is extract-and-executed from source because useMapInstance.js cannot be imported in plain Node.",
|
||||
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
|
||||
"check:published": "what readers are SERVED must be what we BUILT. Every other gate measures the local tree, so all of them stay green while a stale publish serves something else — and manufactures bug reports that cannot be reproduced. Compares the sha384 per bundle. SKIPs when the published host is unreachable (a network gate that goes red on flaky wifi gets ignored).",
|
||||
"check:quoted-scripts": "documentation/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
|
||||
|
|
@ -377,6 +385,7 @@
|
|||
"check:studio-rail": "the Plugin Studio rail is laid out like plugin-designs/: one folder per bundle with its elements inside, bundles in the section of their highest capability (5 \u2192 1) and in name order, one canonical node per plugin. Runs the studio's real rail code on the built manifest, headless.",
|
||||
"check:translator-contacts": "the credit line LINKS the translating organisation now, and a contact URL is a link this map stands behind. Reads ALLOWED_CONTACT out of internal/verify-gospel-map.mjs rather than restating it (two copies of an allowlist drift, and the drifting copy is the one nobody reads), then asserts every cr.u is on that list, was recorded `verified` by a real fetch, is never also serving a RESOURCE (wycliffe.org is somewhere to write to, not a publisher this map reads text from), and is actually RENDERED as an anchor \u2014 the data can carry 838 URLs while the template shows none. Static, sub-second. Self-test: `bun run check:translator-contacts:poison`.",
|
||||
"check:embed-contract": "the map's URL is a PUBLIC INTERFACE: a partner embeds ?codes=<iso3 list>, ?country=<ISO3> or ?view=zero in an iframe and never touches it again. A refactor breaks those links SILENTLY \u2014 the leaf still mounts, renders and logs no errors, it just shows the whole world to a church that asked for its forty groups. Asserts through the reader's eyes (the partner-list notice's two numbers), with both numbers and the probe codes derived from the roster the leaf itself reads, never typed in. Browser gate; needs no server of its own. Self-test: `bun run check:embed-contract:poison`.",
|
||||
"check:engine-contract": "the map must use the engine it was ASKED for or fail VISIBLY \u2014 never a substitute. resources-map read `engineGlobal(provider) || window.mapboxgl`; engineGlobal already defaults to mapbox, so that could only fire for provider 'maplibre' and then returned MAPBOX \u2014 a keyless page silently handed the engine that needs a token, which the profile then does not give it, so its tiles 401. Invisible to every other gate: the leaf mounts, renders and logs nothing. Six runtime measurements (2 providers x engine present / blocked / both blocked, CDN blocked by request routing the way a proxy or an offline kiosk does) plus a static arm on the source, comments stripped so the fix's own quotation of the deleted line is not read as the defect. Mapbox arms inject a dummy MAP_TOKEN, because without one the element never mounts on a static tree and that is the environment, not the product. Engine attribution is read off the canvas class, not a debug handle. Browser gate. Self-test: `bun run check:engine-contract:poison`.",
|
||||
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
|
||||
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
|
||||
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useMapInstance, engineGlobal } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { useMapData } from '@map/composables/useMapData.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -295,7 +295,9 @@ function addLayer(fc) {
|
|||
const pray = doxaRec && doxaRec.slug
|
||||
? `<a href="https://pray.doxa.life/${encodeURIComponent(doxaRec.slug)}?source=doxalife&utm_source=${utmSource()}" target="_blank" rel="noopener">${esc(tr('pray'))}</a>`
|
||||
: ''
|
||||
const NS = mapProvider === 'maplibre' ? window.maplibregl : window.mapboxgl
|
||||
// C3: engineGlobal() resolves a provider NAME to its namespace and already defaults to Mapbox
|
||||
// for anything unrecognised, which is exactly what this ternary's else branch did.
|
||||
const NS = engineGlobal(mapProvider)
|
||||
new NS.Popup({ closeButton: true, maxWidth: '290px' })
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(`<div class="apg-pop">
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
|||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl, engineUiLocale } from '@map/composables/useMapInstance.js'
|
||||
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
|
||||
import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
|
||||
// People-group dots open the SAME detail panel as Prayer Progress (doxa-simple-map): the
|
||||
|
|
@ -372,9 +373,9 @@ onMounted(async () => {
|
|||
if (!gl) {
|
||||
const engine = ciProvider === 'maplibre' ? 'MapLibre' : 'Mapbox'
|
||||
console.warn(`[connectivity-map] the ${engine} engine did not load; refusing to substitute another engine.`)
|
||||
// messages.engineNotLoadedDetail arrives with upg100's ade12e4c; until it does, English is the fallback.
|
||||
const msg = t('messages.engineNotLoadedDetail', { engine })
|
||||
err.value = msg && msg !== 'messages.engineNotLoadedDetail' ? msg : `The ${engine} map engine did not load. The list and legend are unaffected.`
|
||||
// messages.engineMissing is the shared key (studio's landing 3); English only if a host has no catalogue.
|
||||
const msg = t('messages.engineMissing', { engine })
|
||||
err.value = msg && msg !== 'messages.engineMissing' ? msg : `The ${engine} map engine did not load.`
|
||||
return
|
||||
}
|
||||
if (!mapEl.value) { err.value = 'map engine not loaded'; return }
|
||||
|
|
@ -417,6 +418,10 @@ onMounted(async () => {
|
|||
// countries carrying a real attribution requirement (India CC BY 2.5 IN, etc).
|
||||
addMapAttribution(map, { boundaries: true })
|
||||
map.once('load', async () => {
|
||||
// The BASEMAP's place names in the reader's language (Mapbox setLanguage / MapLibre name:<lang>),
|
||||
// the library's own helper. useMapInstance applies it for every map built through it; this map is
|
||||
// built raw, so it was the one map whose country and city names stayed English under ar.
|
||||
try { localizeBasemapLabels(map, readerLang()) } catch (_) { /* a nicety lost, never a broken map */ }
|
||||
paint(map, adm0)
|
||||
selectedPin.initialize()
|
||||
await groupsLoaded
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ function onDashboardFilter({ filterSet = {}, searchTerm = '' } = {}) {
|
|||
// Tier 1 takes a whole url, so it is COMPUTED rather than hardcoded: resolveEmbedBase() is the same
|
||||
// helper ShareButton uses (profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL → the root the
|
||||
// built bundle derives from its own script url), so the link stays correct on doxa.life, on
|
||||
// nuxtbase.app and in a dropped tree with no configuration. Hardcoding an absolute url here would
|
||||
// the live test host and in a dropped tree with no configuration. Hardcoding an absolute url here would
|
||||
// pin every share to whichever host happened to build it.
|
||||
//
|
||||
// Note this is exactly the two-host defect class: research-dashboard standalone PASSES the same
|
||||
|
|
|
|||
|
|
@ -1124,7 +1124,7 @@ export default defineComponent({
|
|||
// ★ THE CORS-READABLE COPY (generate-tree writes it beside this bundle, byte-identical).
|
||||
// A script embed on a partner page FETCHES the manifest cross-origin, and the live static layer
|
||||
// sends Access-Control-Allow-Origin only on paths INSIDE a folder of the tree — every bundle file
|
||||
// and assets/ carry it, the ROOT manifest.json does not (measured 2026-09-25 on nuxtbase: the
|
||||
// and assets/ carry it, the ROOT manifest.json does not (measured 2026-09-25 on the live test host: the
|
||||
// root 200 without it, doxa-maps-spa/<anything> with it, even a 404). So on a foreign page the
|
||||
// copy is read FIRST (no CORS error in the host's console) and the root second; same-origin keeps
|
||||
// the root first. Script URLs still resolve against the TREE ROOT, never the copy's folder.
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponen
|
|||
|
||||
// ─── Composables (framework) ─────────────────────────────────────────────────
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider, providerLabel, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider, providerLabel, providerFor, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js'
|
||||
import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js'
|
||||
import { useMapEvents } from '@map/composables/useMapEvents.js'
|
||||
|
|
@ -1319,7 +1319,9 @@ async function openPoster() {
|
|||
spec.slots.scaleBar = false
|
||||
spec.slots.north = false
|
||||
// Credit the tiles actually drawn: OpenFreeMap on MapLibre, Mapbox otherwise.
|
||||
const onMaplibre = !!(window.maplibregl && map.value instanceof window.maplibregl.Map)
|
||||
// C3: ask the library, not the globals. The explicit === 'maplibre' keeps the original FALSE for
|
||||
// a map that is neither, so the Mapbox attribution stays the fall-through it always was.
|
||||
const onMaplibre = providerFor(map.value) === 'maplibre'
|
||||
spec.slots.footer = { ...spec.slots.footer, left: 'pray.doxa.life', right: new Date().toISOString().slice(0, 10),
|
||||
attribution: onMaplibre ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap' : '© Mapbox © OpenStreetMap' }
|
||||
const blob = await exporter.render(spec, 'png')
|
||||
|
|
@ -2234,9 +2236,18 @@ function emitFilterState() {
|
|||
const detail = {
|
||||
region: mapStore?.selectedRegion || '',
|
||||
religion: religionName,
|
||||
// A dialect selection reports the dialect's own display name, so a host that shows the value
|
||||
// shows what the user clicked rather than its parent.
|
||||
language: dialectLabels.length ? (dialect?.key ? `${langName}, ${dialect.key}` : langName) : langName,
|
||||
// ★ A DIALECT'S NAME COMES FROM ITS LABELS, NOT ITS KEY (rega, row 43 case 3: this reported
|
||||
// ", Afro-Asiatic__Arabic__Sudanese"). `selectedDialect.key` is the TREE NODE ID —
|
||||
// family__baseLang__dialect — and the watcher fifteen lines above says so in as many words;
|
||||
// I read that comment and then used the key as a display name anyway. Worse, selectedLanguage
|
||||
// is empty by the time a dialect is active, so the template produced a leading comma and an
|
||||
// internal identifier: a string no reader could make sense of, in the one slot meant for a
|
||||
// human name.
|
||||
// dialectLabels are the ORIGINAL API labels the legend grouped ("Arabic, Sudanese"), so they
|
||||
// are both the right words and already in the reader's locale. The first is the display name;
|
||||
// when a dialect groups several labels the whole list is the honest answer, because that is
|
||||
// what the selection covers.
|
||||
language: dialectLabels.length ? dialectLabels.join(' · ') : langName,
|
||||
languageCode: langCode,
|
||||
// ADDITIVE: every code the selection covers, so a consumer with multi-value filtering can
|
||||
// narrow correctly where a single code cannot express the selection. Existing listeners that
|
||||
|
|
@ -2245,12 +2256,36 @@ function emitFilterState() {
|
|||
}
|
||||
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
|
||||
// ★ ONE EVENT PER SETTLED SELECTION (rega, row 43 case 3: a dialect click narrowed the grid in
|
||||
// only ONE of four runs, with a correct languageCode in all four).
|
||||
//
|
||||
// THE CAUSE IS A BURST MEETING A GUARD. Clicking a dialect does not change one store value; it
|
||||
// clears the language, sets the family and sets the dialect, across ticks, so the watcher below
|
||||
// fired three or four times and this element dispatched three or four map-filtered events for one
|
||||
// click. The composite ignores anything that arrives while it is pushing state INTO its children —
|
||||
// a guard it needs, because the grid re-emits during a push and the round trip would otherwise
|
||||
// cancel the very selection just made — and that window is 1.2 seconds. So whether the LAST event
|
||||
// in the burst, the one carrying the settled state, arrived inside or outside the window was
|
||||
// timing. Three runs lost it; one did not.
|
||||
//
|
||||
// Coalescing here is the fix that belongs to this file: the other end's guard is correct, and the
|
||||
// burst is what this element should not have been sending. One click, one event, nothing to drop.
|
||||
// A short window rather than nextTick because the cascade crosses ticks (the legend bridge does
|
||||
// async work); 60ms is far below the 1.2s guard and below what a reader perceives as a delay.
|
||||
let _emitTimer = null
|
||||
const EMIT_SETTLE_MS = 60
|
||||
function emitFilterStateSettled() {
|
||||
clearTimeout(_emitTimer)
|
||||
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(_emitTimer))
|
||||
// selectedDialect joins the sources: it is the deepest tier of the Language tree and a genuine
|
||||
// language selection, but it was not observed here, so drilling from "Arabic" to "Arabic, Sudanese"
|
||||
// changed the map and left the grid reporting the tier above. `.key` rather than the object,
|
||||
// because the store replaces it wholesale and a deep watch here would re-emit on every re-render.
|
||||
watch(() => [mapStore?.selectedRegion, mapStore?.selectedLanguage, mapStore?.selectedDialect?.key, uiStore?.religionFilter],
|
||||
() => emitFilterState())
|
||||
() => emitFilterStateSettled())
|
||||
|
||||
// ─── Universal-legend (LegendRows) → applyDimFilter bridge ───────────────────
|
||||
// LegendRows / useLegendData routes row-clicks through mapStore.selectFamily()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
* family > language > dialect and derives the language tier by splitting the API label at the comma.
|
||||
* MEASURED on the live source: 2,107 rows, all carrying primary_language as {value,label}, and NO
|
||||
* row whose label is "Arabic" — what exists is "Arabic, Sudanese", "Arabic, Levantine", "Arabic,
|
||||
* Gulf", 22 codes in all. The exact match could never hit, in any locale, which is why it failed
|
||||
* Gulf", 23 codes in all. The exact match could never hit, in any locale, which is why it failed
|
||||
* identically in en and ar.
|
||||
*
|
||||
* ★ THE NUMBERS THIS GATE PINS, measured on the committed fixture (864 distinct label/value pairs,
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
* OLD exact-label rule: 61 tiers resolved "" -> the grid went to zero. "Arabic" was one.
|
||||
* NEW rule: 764 tiers resolve ONE code (the grid filters), 41 cover several and
|
||||
* abstain honestly.
|
||||
* The 41 are real ambiguity, not a bug: "Arabic" alone means 22 ISO codes, so no single value can
|
||||
* The 41 are real ambiguity, not a bug: "Arabic" alone means 23 ISO codes, so no single value can
|
||||
* express it. Sending one of them would show a fraction of the selection while looking correct;
|
||||
* sending the tier NAME shows zero. Abstaining is the only option that does not lie, until
|
||||
* filterSet goes multi-value (SPEC-DASHBOARD-KIT D1).
|
||||
|
|
@ -40,10 +40,15 @@
|
|||
* next bug, so the gate compares the two that exist.
|
||||
* C. for every base tier: exactly one code -> a NON-EMPTY languageCode; several -> an EMPTY
|
||||
* languageCode AND a populated languageCodes.
|
||||
* D. the composite fails OPEN: gridLanguage() must return '' when the selection covers several
|
||||
* codes. Returning the label there is what produced the empty grid.
|
||||
* D. the composite sends the WHOLE code set when a selection covers several (SPEC-DASHBOARD-KIT
|
||||
* D1), and never the tier NAME. Before D1 the honest answer was to abstain, because the grid
|
||||
* could hold one value; now that it takes a list, abstaining would leave a selection
|
||||
* unexpressed that the receiving end can express. Asserted per tier: the joined set must
|
||||
* select exactly the rows the map selected.
|
||||
* E. no tier may produce a value that selects zero rows. That is the user-visible invariant and
|
||||
* it is asserted directly, not inferred.
|
||||
* F. a DIALECT reports a NAME and not a tree node id, and the emitter sends ONE event per
|
||||
* settled selection rather than a burst. Both from rega's row 43 case 3.
|
||||
*
|
||||
* NO NETWORK, NO BROWSER (Playwright is rega's alone). The fixture is committed and was taken from
|
||||
* the live source; refresh it with --refresh-fixture when the source's language set changes.
|
||||
|
|
@ -169,12 +174,21 @@ if (matchesLanguage) {
|
|||
many++
|
||||
if (languageCode) fails.push(`C "${base}" covers ${codes.length} codes but still emitted a single languageCode — one of them would show a fraction of the selection while looking correct.`)
|
||||
if (!codes.length) fails.push(`C "${base}" resolved no codes at all.`)
|
||||
// D1: the SET is what the grid now filters on, so it has to select exactly the rows the map
|
||||
// selected. Asserted per tier rather than on one example, because a join that dropped or
|
||||
// duplicated a code would still look plausible on "Arabic".
|
||||
const joined = codes.join('|')
|
||||
const wants = new Set(joined.split('|'))
|
||||
const gridRows = rows.filter((r) => wants.has(r.primary_language.value))
|
||||
if (gridRows.length !== selected.length) {
|
||||
fails.push(`C "${base}": the map selects ${selected.length} row(s) but the joined code set selects ${gridRows.length} — the two ends of the link disagree.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (zeroSelecting.length) {
|
||||
fails.push(`E ${zeroSelecting.length} language tier(s) select ZERO rows, so clicking them empties the grid: ${zeroSelecting.slice(0, 6).join(', ')}`)
|
||||
}
|
||||
notes.push(`tiers: ${byBase.size} — ${single} resolve one code (the grid filters), ${many} cover several (the axis is left open)`)
|
||||
notes.push(`tiers: ${byBase.size} — ${single} resolve one code, ${many} cover several and cross as the whole set (D1); every tier narrows the grid to the rows the map selected`)
|
||||
if (many === 0) fails.push('C no tier in the fixture covers several codes, so the ambiguous path is untested — the fixture was sampled rather than deduplicated.')
|
||||
}
|
||||
|
||||
|
|
@ -189,13 +203,58 @@ if (matchesLanguage) {
|
|||
if (fn) {
|
||||
const many = { language: { value: '', label: 'Arabic', codes: ['apd', 'acm', 'ayl'] } }
|
||||
const one = { language: { value: 'ben', label: 'Bengali', codes: ['ben'] } }
|
||||
if (fn(many) !== '') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(many))} for a selection covering 3 codes — it must return '' and leave the axis unfiltered. Returning the label is what took the grid to 0.`)
|
||||
// ★ UPDATED FOR D1. This used to require '' for a multi-code tier, and that was the right
|
||||
// answer while the grid could hold one value: the map narrowed, the grid did not, and the
|
||||
// reader could see both. Now that valueMatches() takes a LIST, abstaining would be leaving a
|
||||
// selection unexpressed that the receiving end can express — so the whole code set crosses.
|
||||
// What has NOT changed, and is asserted below, is that it never sends the tier NAME: "Arabic"
|
||||
// matches no row reading "Arabic, Sudanese", and that is what emptied the grid originally.
|
||||
const got = fn(many)
|
||||
if (got !== 'apd|acm|ayl') fails.push(`D gridLanguage() returned ${JSON.stringify(got)} for a selection covering 3 codes — it must send all three, pipe-joined, so the grid narrows to the same rows the map did. (`|` and not `,`: a language label routinely contains a comma.)`)
|
||||
if (/[A-Z]/.test(String(got))) fails.push(`D gridLanguage() returned ${JSON.stringify(got)}, which looks like a display NAME rather than codes — a tier name matches no row and empties the grid.`)
|
||||
if (fn(one) !== 'ben') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(one))} for an unambiguous selection — it must send the single code.`)
|
||||
if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the code when there is one and abstains when there is not')
|
||||
if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the single code, or every code in the selection — never the tier name')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── E. the dialect tier: a name, and one event ───────────────────────────────────────────────
|
||||
// rega, row 43 case 3, on BOTH engines in BOTH locales: clicking "Arabic, Sudanese" emitted the
|
||||
// right languageCode and narrowed the grid in only ONE of four runs, while the `language` slot read
|
||||
// ", Afro-Asiatic__Arabic__Sudanese".
|
||||
// THE NAME: selectedDialect.key is the TREE NODE ID (family__baseLang__dialect) and
|
||||
// selectedLanguage is empty by the time a dialect is active, so the template produced a leading
|
||||
// comma and an internal identifier. The labels the legend grouped are the right words, and they
|
||||
// are already in the reader's locale.
|
||||
// THE ONE EVENT: a dialect click changes several store values across ticks, so the watcher fired
|
||||
// three or four times and this element dispatched a BURST for one click. The composite ignores
|
||||
// anything arriving while it pushes state into its children — a guard it needs, because the grid
|
||||
// re-emits during a push — and that window is 1.2s. Whether the settled event landed inside it
|
||||
// was timing. Three runs lost it; one did not.
|
||||
// Both are shapes, so both are asserted against the source and neither needs a browser.
|
||||
{
|
||||
const src = emitterSrc
|
||||
const line = /language: ([^\n]*),\n/.exec(src.slice(src.indexOf('const detail = {')))
|
||||
if (!line) fails.push('E research-map.vue: could not find the `language:` field of the map-filtered detail — this gate is blind to the name it reports.')
|
||||
else {
|
||||
if (/dialect\??\.key/.test(line[1])) fails.push(`E the dialect's display name is built from selectedDialect.key, which is the TREE NODE ID (family__baseLang__dialect), not a name: ${line[1].trim()}`)
|
||||
if (!/dialectLabels/.test(line[1])) fails.push(`E the dialect's display name does not come from dialectLabels (the original API labels, already localized): ${line[1].trim()}`)
|
||||
}
|
||||
if (!/clearTimeout\(_emitTimer\)[\s\S]{0,200}setTimeout\(emitFilterState/.test(src)) {
|
||||
fails.push('E research-map.vue no longer coalesces map-filtered — one click emits a BURST, and the composite drops whatever arrives inside its 1.2s push window, so whether the settled state lands is timing.')
|
||||
} else {
|
||||
// ★ THE WATCHER LINE SPECIFICALLY, not the file. A first version searched the whole source for
|
||||
// `emitFilterStateSettled()`, which the helper's own declaration satisfies — so reverting the
|
||||
// WATCHER to the raw emit left the gate green while the burst came back. A check that its own
|
||||
// subject can satisfy is not a check.
|
||||
const w = /watch\(\(\) => \[mapStore\?\.selectedRegion[\s\S]{0,240}?\)\n/.exec(src)
|
||||
if (!w) fails.push('E could not find the emitter watcher — this gate is blind to whether the burst is coalesced.')
|
||||
else if (!/emitFilterStateSettled\(\)/.test(w[0])) {
|
||||
fails.push('E the settle helper exists but the WATCHER still calls the raw emit, so one click still dispatches a burst.')
|
||||
} else notes.push('dialect: named from its labels, and one event per settled selection')
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(' · ' + n)
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG}: FAIL`)
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ import LocationButton from '@map/components/map-controls/LocationButton.vue'
|
|||
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider, providerLabel } from '@map/composables/useMapProvider.js'
|
||||
import { ensureEngineScript, engineGlobal, engineStylesheetUrl, ensureEngineCSS } from '@map/composables/useMapInstance.js'
|
||||
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
|
||||
import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||
|
|
@ -782,12 +782,23 @@ const pinsByIso = computed(() => {
|
|||
/* The fallback resolves against the SHIPPED ENGLISH CATALOG (imported at the top), not the
|
||||
key. Returning the last dot-segment would have put "gap" and "deathsDay" on screen — a
|
||||
broken-looking label is not graceful degradation, it just moves the bug. */
|
||||
/* Walks from the ROOT of the catalogue, not from its gospel subtree: this leaf now also reads
|
||||
`pins.*`, the shared people-group-pin wording, and an area-blind lookup would have fallen back
|
||||
to the key name for every one of those on a host with no i18n. */
|
||||
const enLookup = (key) =>
|
||||
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
|
||||
let t = (key) => enLookup(key) ?? String(key)
|
||||
String(key).split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES || {})
|
||||
/* The fallback interpolates too. vue-i18n does it for the translated path; without this the
|
||||
English arm would render "{names} — {n} groups share…" literally. */
|
||||
const fill = (str, params) => (params
|
||||
? String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] == null ? m : String(params[k])))
|
||||
: str)
|
||||
let t = (key, params) => fill(enLookup(key) ?? String(key), params)
|
||||
try {
|
||||
const i18n = useI18n()
|
||||
t = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
|
||||
t = (key, params) => {
|
||||
const v = params ? i18n.t(key, params) : i18n.t(key)
|
||||
return v === key ? fill(enLookup(key) ?? key, params) : v
|
||||
}
|
||||
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
|
||||
/* Whether to show the "these notes are in English" line at all. Read from the same i18n
|
||||
instance the labels come from, so the two can never disagree. */
|
||||
|
|
@ -803,7 +814,10 @@ const readerLang = () => {
|
|||
t() falls back to the key when a catalogue has no entry, which on a `title` attribute would
|
||||
put "gospel.legend.hnrTip" in a tooltip: worse than the English it replaced. This keeps the
|
||||
English in the template until the key lands, and switches the moment it does. */
|
||||
const tOr = (key, english) => { const v = t(key); return v && v !== key ? v : english }
|
||||
const tOr = (key, english, params) => {
|
||||
const v = t(key, params)
|
||||
return v && v !== key ? v : fill(english, params)
|
||||
}
|
||||
|
||||
const SE_KIND = { PDF: 'PDF', audio: 'audio', sab: 'app', study: 'study notes', viewer: 'read online' }
|
||||
const seKinds = (k) => (Array.isArray(k) ? k : []).map((x) => SE_KIND[x] || x).join(', ')
|
||||
|
|
@ -1487,8 +1501,21 @@ onMounted(async () => {
|
|||
/* Re-check after the await: this leaf can be unmounted mid-initialise, which is the race
|
||||
that produced "Cannot read properties of null" across the fleet. */
|
||||
if (!mapEl.value) return
|
||||
const gl = engineGlobal(grProvider) || window.mapboxgl
|
||||
if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return }
|
||||
/* ⚠ NEVER SWITCH ENGINES SILENTLY (regions' C3 read, 2026-09-26). This used to read
|
||||
`engineGlobal(grProvider) || window.mapboxgl`. engineGlobal already defaults to mapbox, so
|
||||
the fallback could only ever fire for provider 'maplibre' — and then it handed back MAPBOX:
|
||||
a page that chose the keyless engine got the one that needs a token, which breaks the
|
||||
keyless contract silently, with a map that looks fine until the token is missing. The
|
||||
requested engine or a visible failure; never a different engine. */
|
||||
const gl = engineGlobal(grProvider)
|
||||
if (!gl) {
|
||||
/* The console line stays English on purpose — it is for whoever is debugging the page, not
|
||||
for the reader. The line the READER sees goes through the catalogue. */
|
||||
console.warn(`[gospel-resources] the ${grProvider} engine global never loaded — refusing to substitute another engine, because that would hand a keyless page the engine that needs a token.`)
|
||||
err.value = tOr('messages.engineMissing', 'The {engine} map engine did not load.', { engine: providerLabel(grProvider) })
|
||||
return
|
||||
}
|
||||
if (!mapEl.value) return
|
||||
/**
|
||||
* ═══ ARABIC, HEBREW AND PERSIAN PLACE NAMES NEED A SHAPER ═══════════════════════════════
|
||||
*
|
||||
|
|
@ -1525,7 +1552,7 @@ onMounted(async () => {
|
|||
the locale map the shared composable passes — "Toggle attribution" and the canvas's
|
||||
accessible name "Map" stayed English under ar while every string we own was Arabic.
|
||||
Set at construction because that is the only time either engine reads it. */
|
||||
locale: engineUiLocale(readerLang()),
|
||||
locale: engineUiLocale(readerLang(), grProvider),
|
||||
/* A starting zoom is a guess about the canvas. It was 1.3, which framed the world
|
||||
while this leaf was running WITHOUT the engine's stylesheet; once mapbox-gl.css
|
||||
reached the shadow root the canvas sized correctly and the same 1.3 cut the visible
|
||||
|
|
@ -1755,14 +1782,20 @@ useShadowStyles(
|
|||
'.gm-linklist span{color:#6d766e}' +
|
||||
'.is-dark .gm-linklist{background:rgba(59,70,61,.6);border-color:#6d766e;color:#f3f3f1}' +
|
||||
'.is-dark .gm-linklist span{color:#d1d4cd}' +
|
||||
'.gm-legend{position:absolute;top:8px;left:8px;width:var(--map-legend-width,380px);' +
|
||||
/* ⚠ LOGICAL, NOT PHYSICAL (Driver-found, rega measured on main 52eeb0db). Under ar the five
|
||||
map controls mirror to the LEFT while `left:8px` kept the legend physically left, so the
|
||||
legend sat underneath all five of them. cities-internet shares this class name and passed
|
||||
for one reason: it anchors inline. Every physical left/right in this stylesheet that has a
|
||||
direction-dependent meaning is now logical — the anchor, the search clear button, the
|
||||
accent borders and the header's push-right. */
|
||||
'.gm-legend{position:absolute;top:8px;inset-inline-start:8px;width:var(--map-legend-width,380px);' +
|
||||
'max-height:calc(100% - 16px);overflow-y:auto;overscroll-behavior:contain;' +
|
||||
'max-width:calc(100% - 16px);box-sizing:border-box;z-index:4;padding:8px 10px;' +
|
||||
'border-radius:10px;background:rgba(255,255,255,.94);color:#0f1419;' +
|
||||
"font:12px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.16)}" +
|
||||
// Narrow screens: the rail is still on the right, so the legend must stop short
|
||||
// of it rather than run under it. 46px = 32px button + 3px inset + clearance.
|
||||
'@media(max-width:480px){.gm-legend{width:auto;right:46px}}' +
|
||||
'@media(max-width:480px){.gm-legend{width:auto;inset-inline-end:46px}}' +
|
||||
'.is-dark .gm-legend{background:rgba(19,32,25,.95);color:#e5e7eb}' +
|
||||
'.gm-title{font-weight:700;font-size:15.5px;margin-bottom:5px}' +
|
||||
'.gm-keys{display:flex;flex-wrap:wrap;gap:5px 12px}' +
|
||||
|
|
@ -1825,7 +1858,7 @@ useShadowStyles(
|
|||
'.is-dark .gm-sheet{background:#132019;color:#e5e7eb}' +
|
||||
'.gm-sh-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}' +
|
||||
'.gm-sh-name{font-weight:800;font-size:18px}' +
|
||||
'.gm-x{margin-left:auto;border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
|
||||
'.gm-x{margin-inline-start:auto;border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
|
||||
'font:600 12px inherit;padding:6px 12px;border-radius:8px;cursor:pointer;min-height:34px}' +
|
||||
'.gm-stat{display:flex;justify-content:space-between;gap:12px;padding:4px 0;' +
|
||||
'border-bottom:1px solid rgba(0,0,0,.07);font-variant-numeric:tabular-nums}' +
|
||||
|
|
@ -1839,7 +1872,7 @@ useShadowStyles(
|
|||
'.gm-search{position:relative;margin-bottom:6px}' +
|
||||
'.gm-q{width:100%;box-sizing:border-box;border:1px solid #d1d4cd;border-radius:8px;background:#fff;color:#3b463d;font:12px inherit;padding:7px 26px 7px 9px;min-height:32px}' +
|
||||
'.gm-q:focus{outline:2px solid #92b195;outline-offset:1px}' +
|
||||
'.gm-qx{position:absolute;right:4px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:#6d766e;font-size:19px;line-height:1;cursor:pointer;padding:4px 6px;min-height:24px}' +
|
||||
'.gm-qx{position:absolute;inset-inline-end:4px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:#6d766e;font-size:19px;line-height:1;cursor:pointer;padding:4px 6px;min-height:24px}' +
|
||||
'.gm-qres{margin:-2px 0 7px;border:1px solid #d1d4cd;border-radius:8px;background:#fff;overflow:hidden}' +
|
||||
'.gm-qrow{display:flex;align-items:baseline;gap:6px;width:100%;text-align:left;border:0;background:transparent;font:inherit;font-size:14px;color:#3b463d;padding:6px 8px;cursor:pointer;min-height:30px;border-bottom:1px solid #eceee9}' +
|
||||
'.gm-qrow:last-child{border-bottom:0}' +
|
||||
|
|
@ -1871,18 +1904,18 @@ useShadowStyles(
|
|||
'.is-dark .gm-gtab.on{background:#92b195;color:#10241a}' +
|
||||
'.gm-pg-rel{display:block;margin-top:2px;font-size:13px;color:#6d766e;font-style:italic}' +
|
||||
'.is-dark .gm-pg-rel{color:#9fb3a4}' +
|
||||
'.gm-rel{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-left:3px solid #92b195;' +
|
||||
'.gm-rel{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-inline-start:3px solid #92b195;' +
|
||||
'border-radius:6px;background:#fff;font-size:14.5px;line-height:1.5}' +
|
||||
'.gm-rel code{font-size:14px;background:#f3f3f1;padding:1px 4px;border-radius:3px}' +
|
||||
'.is-dark .gm-rel{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
|
||||
'.gm-sign{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-left:3px solid #6d766e;' +
|
||||
'.gm-sign{margin:8px 0 0;padding:8px 10px;border:1px solid #d1d4cd;border-inline-start:3px solid #6d766e;' +
|
||||
'border-radius:8px;background:#f3f3f1;color:#3b463d;font-size:14.5px;line-height:1.5}' +
|
||||
'.is-dark .gm-sign{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
|
||||
'.gm-pray{display:block;margin:8px 0 0;padding:9px 11px;border-radius:8px;text-decoration:none;' +
|
||||
'background:#3b463d;color:#fff;font:600 12.5px inherit;text-align:center}' +
|
||||
'.gm-pray:hover,.gm-pray:focus-visible{background:#2f382f}' +
|
||||
'.gm-pray-n{display:block;margin-top:2px;font-weight:500;font-size:14px;opacity:.85}' +
|
||||
'.gm-tablink{display:block;margin:10px 0 2px;padding:9px 12px;border:1px solid #d1d4cd;border-left:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-weight:600;font-size:15.5px;text-decoration:none;min-height:36px;box-sizing:border-box}' +
|
||||
'.gm-tablink{display:block;margin:10px 0 2px;padding:9px 12px;border:1px solid #d1d4cd;border-inline-start:3px solid #92b195;border-radius:8px;background:#f3f3f1;color:#3b463d;font-weight:600;font-size:15.5px;text-decoration:none;min-height:36px;box-sizing:border-box}' +
|
||||
'.gm-tablink:hover{background:#e9ebe6}' +
|
||||
'.is-dark .gm-tablink{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.18);color:#e5e7eb}' +
|
||||
'.gm-stat-alt span{opacity:.72}' +
|
||||
|
|
@ -2066,14 +2099,17 @@ useShadowStyles(
|
|||
all-covered wording says what it means. Still two different claims: every group
|
||||
has SOMEWHERE TO START, and not one of them has the gospel in their own
|
||||
language. -->
|
||||
<template v-if="pinTally.wayIn === pinTally.none">
|
||||
<b>Every one of those {{ fmt(pinTally.none) }}</b> has a possible way in
|
||||
</template>
|
||||
<template v-else>
|
||||
<b>{{ fmt(pinTally.wayIn) }} of those {{ fmt(pinTally.none) }}</b> have a possible way in
|
||||
</template>
|
||||
— a bridge language, or SUN for a Deaf group. Tap one to see it. A way in is not
|
||||
the gospel in their own language; the gap is unchanged.
|
||||
<!-- ⚠ THIS SENTENCE IS NOT PROSE AND MUST NOT STAY ENGLISH. It is the legend's one
|
||||
piece of news — that almost every group with nothing has somewhere to start — and
|
||||
rega measured it as the Latin left in an Arabic legend once the explainer was
|
||||
folded away. Through tOr() with its numbers as parameters, so it renders today's
|
||||
English until the key lands and then switches. The CAVEAT that followed it ("a way
|
||||
in is not the gospel in their own language") moved into the fold below, where the
|
||||
rest of the basis lives: it qualifies the news rather than being it. -->
|
||||
<b>{{ pinTally.wayIn === pinTally.none
|
||||
? tOr('gospel.guide.wayInAll', 'Every one of those {n}', { n: fmt(pinTally.none) })
|
||||
: tOr('gospel.guide.wayInSome', '{n} of those {total}', { n: fmt(pinTally.wayIn), total: fmt(pinTally.none) }) }}</b>
|
||||
{{ tOr('gospel.guide.wayInWhat', 'has a way in — a bridge language, or SUN for a Deaf group. Tap one to see it.') }}
|
||||
</p>
|
||||
<!-- Not a colour, so it carries no dot: this cuts ACROSS the three tiers rather
|
||||
than naming one of them. It is the translation-priority question. -->
|
||||
|
|
@ -2116,8 +2152,10 @@ useShadowStyles(
|
|||
<br><b>{{ t('gospel.guide.noneFoundShort') }}</b>
|
||||
<br><b>{{ t('gospel.guide.tapCountry') }}</b>
|
||||
<details class="gm-why">
|
||||
<summary class="gm-why-sum">How these numbers are made</summary>
|
||||
<summary class="gm-why-sum">{{ tOr('gospel.guide.howNumbersMade', 'How these numbers are made') }}</summary>
|
||||
<div class="gm-why-body">
|
||||
<span dir="ltr">A way in is not the gospel in someone's own language; the gap it is
|
||||
counted against is unchanged by it.</span>
|
||||
<span dir="ltr"> A country is a COUNT of those groups — how many of
|
||||
Doxa's are there with nothing found — never a percentage: 106 of these 175 countries hold
|
||||
one or two groups, and a share over a denominator of two says nothing. A country Doxa does
|
||||
|
|
@ -2161,12 +2199,32 @@ useShadowStyles(
|
|||
</div>
|
||||
<p v-if="selectedProvince.zero_access_examples" class="gm-note" dir="ltr" style="margin:6px 0 0">
|
||||
Largest with nothing: <b>{{ selectedProvince.zero_access_examples }}</b>
|
||||
<template v-if="selectedProvince.ways_in"><br>Way in: {{ selectedProvince.ways_in }} — chosen for the country, not this province.</template>
|
||||
<!-- The basis travels WITH the answer. This line used to read "chosen for the country, not
|
||||
this province" on every row, which was true then and is now true of five rows in
|
||||
forty-six; printing it on all of them would understate the other forty-one and
|
||||
printing none of them would overstate these five. -->
|
||||
<template v-if="selectedProvince.ways_in"><br>Way in: {{ selectedProvince.ways_in }} — {{ selectedProvince.ways_in_basis || 'country-level' }}.</template>
|
||||
</p>
|
||||
<!-- N of M, the same rule as the country sheet: the denominator is the groups HERE with a
|
||||
prayer page, never the province. -->
|
||||
<div class="gm-stat" v-if="selectedProvince.prayer_pages">
|
||||
<span>{{ t('gospel.tabs.noprayer') }}</span>
|
||||
<b>{{ fmt(selectedProvince.nobody_praying_groups) }} / {{ fmt(selectedProvince.prayer_pages) }}</b>
|
||||
</div>
|
||||
<p class="gm-note" dir="ltr" style="margin:6px 0 0">
|
||||
A group is placed in a province from its own coordinate, which marks where a people is
|
||||
found rather than a settlement. Near a border that point can fall on the wrong side.
|
||||
</p>
|
||||
<!-- ═══ THE MOVE, AT THE GRAIN A TEAM IS SENT TO ══════════════════════════════════════
|
||||
A country is not where anyone is sent; a state is. Same ranking as the country sheet —
|
||||
need first, then size — so drilling down never hands the reader a second answer. -->
|
||||
<a v-if="selectedProvince.pray_first" class="gm-pray" :href="selectedProvince.pray_first.url"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
:title="'Open the prayer page for ' + selectedProvince.pray_first.name + ' on doxa.life.'">
|
||||
{{ t('gospel.sheet.pray') }} {{ selectedProvince.pray_first.name }}
|
||||
<span class="gm-pray-n" dir="ltr">{{ fmt(selectedProvince.pray_first.people) }}
|
||||
people<template v-if="selectedProvince.pray_first.has_nothing">, nothing found</template></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selected || showGapList" class="gm-sheet">
|
||||
|
|
@ -2337,14 +2395,16 @@ useShadowStyles(
|
|||
of the 2,107 placed groups sit on a coordinate they share with another group. Where
|
||||
this one does, it names them, because "approximate" on its own hides the part a
|
||||
reader would never otherwise discover — that some dots are literally the same dot.
|
||||
English prose in a dir="ltr" block, per the standing decision on caveats. -->
|
||||
<p class="gm-note" dir="ltr" style="margin:6px 0 0">
|
||||
The dot marks where this people is found, not a town or a boundary.
|
||||
TRANSLATED NOW, in 11 locales, from the shared pins.* area (APPROXPIN). Read through
|
||||
this leaf's own guarded t() rather than by mounting library ApproxPinNote.vue, which
|
||||
calls useI18n() unguarded: this bundle is embeddable in hosts that never called
|
||||
app.use(i18n), where that throws during setup and the shadow boundary swallows it —
|
||||
the failure that blanked a production plugin once already. Same wording either way;
|
||||
it is this map's P5 proposal that became the library's. -->
|
||||
<p class="gm-note" :dir="isEnglish ? 'ltr' : null" style="margin:6px 0 0">
|
||||
{{ t('pins.approximate') }}
|
||||
<template v-if="sharedWith(selectedGroup).length">
|
||||
It sits on the same point as
|
||||
<b>{{ sharedWith(selectedGroup).join(', ') }}</b> — {{ sharedWith(selectedGroup).length + 1 }}
|
||||
groups share this coordinate, so the position tells you the locality and not which
|
||||
group is where.
|
||||
{{ t('pins.sharedPoint', { names: sharedWith(selectedGroup).join(', '), n: sharedWith(selectedGroup).length + 1 }) }}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
|
|
@ -2663,7 +2723,7 @@ useShadowStyles(
|
|||
</p>
|
||||
|
||||
<p v-if="selectedGroup.is_sign" class="gm-sign">
|
||||
<b>This is a sign language.</b> A Deaf group cannot be reached by a text
|
||||
<b>{{ tOr('gospel.guide.isSignLanguage', 'This is a sign language.') }}</b> A Deaf group cannot be reached by a text
|
||||
translation, so eBible's silence here is not an absence of Scripture — it is a
|
||||
text pool that was never the right place to look. What counts is Scripture
|
||||
<em>filmed in this sign language</em>. Of {{ fmt(signTally.langs) }} sign languages
|
||||
|
|
@ -2882,7 +2942,7 @@ useShadowStyles(
|
|||
</div>
|
||||
<div v-if="withResources.length > shown" class="gm-more">
|
||||
Showing {{ shown }} of {{ withResources.length }}.
|
||||
<button type="button" class="gm-back" @click="shown = withResources.length">Show all groups</button>
|
||||
<button type="button" class="gm-back" @click="shown = withResources.length">{{ t('gospel.legend.showAll') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2903,7 +2963,7 @@ useShadowStyles(
|
|||
</button>
|
||||
<div v-if="withoutResources.length > shown" class="gm-more">
|
||||
Showing {{ shown }} of {{ withoutResources.length }}.
|
||||
<button type="button" class="gm-back" @click="shown = withoutResources.length">Show all groups</button>
|
||||
<button type="button" class="gm-back" @click="shown = withoutResources.length">{{ t('gospel.legend.showAll') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -82,6 +82,21 @@ const wayIn = (code, iso3, name) => {
|
|||
return b ? b.l : ''
|
||||
}
|
||||
|
||||
/* ═══ PRAYER, AT THE GRAIN A TEAM IS ACTUALLY SENT TO ═══════════════════════════════════════
|
||||
* The country sheet already ends in a prayer move. A country is not where anyone is sent — a state
|
||||
* is — so the province carries the same two numbers. The JOIN IS BY NAME+COUNTRY, not by language,
|
||||
* because you pray for a people in a place; that is doxa.life's own key and the one place in this
|
||||
* build where a name match is correct rather than forbidden.
|
||||
*
|
||||
* ⚠ THE DENOMINATOR IS THE GROUPS WITH A PRAYER PAGE, never the province. doxa.life publishes a
|
||||
* page for 1,723 of the roster, so "nobody praying" can only be said of groups that HAVE one, and
|
||||
* both numbers ship so no surface can quote the first without the second.
|
||||
*/
|
||||
let pray = {}
|
||||
try { pray = T('pray-index.json').byKey || {} } catch { /* no index: the columns ship as null, never as zero */ }
|
||||
const prayKey = (name, iso3) =>
|
||||
`${String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()}|${String(iso3 || '').toUpperCase()}`
|
||||
|
||||
/* ⚠ INDIA READS THE -full FILE, NOT gb-IND-ADM1.geojson. The unsuffixed file is the UPG-100
|
||||
list's own curated partition — 34 listId-tagged units — and this table was built on it for one
|
||||
build, which is why eleven groups had no province: they were standing in states the partition
|
||||
|
|
@ -114,6 +129,51 @@ for (const c of COUNTRIES) {
|
|||
}
|
||||
for (const [province, list] of by) {
|
||||
const zero = list.filter((r) => (tiers[r.iso639_3] ?? r.tier ?? 0) === 0)
|
||||
/* ═══ A WAY IN CHOSEN FOR THIS PROVINCE, NOT FOR THE COUNTRY ══════════════════════════
|
||||
* The caveat this table used to carry said it plainly: Arunachal Pradesh, in the far
|
||||
* north-east, was offered TELUGU — a South Indian language — because the curated bridge
|
||||
* table is keyed by country and a curated bridge outranks a computed one. India's computed
|
||||
* default is Hindi, defensible nationally and wrong for much of the north-east.
|
||||
*
|
||||
* So the province answers for itself. The candidates are the languages of groups STANDING
|
||||
* IN THIS PROVINCE that already have Scripture AND film (tier 2), ranked by how many people
|
||||
* here already hold them. That is a measured neighbour, not a national average: a language
|
||||
* with 4 million people in this state is a plausible second language for its neighbours in a
|
||||
* way that a language from 1,500 km away is not.
|
||||
*
|
||||
* It never invents one. A province with no tier-2 language of its own keeps the country-level
|
||||
* answer and SAYS so, because "no local bridge exists" is itself worth reading.
|
||||
*/
|
||||
const localTier2 = new Map()
|
||||
for (const r of list) {
|
||||
const code = r.iso639_3
|
||||
if (!code || (tiers[code] ?? r.tier ?? 0) !== 2) continue
|
||||
if (isDeaf(code, r.name)) continue
|
||||
const cur = localTier2.get(code) || { code, language: (codes[code] && codes[code].l) || code, people: 0, groups: 0 }
|
||||
cur.people += r.population || 0
|
||||
cur.groups += 1
|
||||
localTier2.set(code, cur)
|
||||
}
|
||||
const local = [...localTier2.values()].sort((a, b) => b.people - a.people).slice(0, 3)
|
||||
|
||||
/* Counted over the groups in THIS province that have a prayer page. */
|
||||
let prayPages = 0, noPray = 0, noPrayPeople = 0
|
||||
let move = null, moveRank = -1
|
||||
for (const r of list) {
|
||||
const e = pray[prayKey(r.name, r.iso3)]
|
||||
if (!e) continue
|
||||
prayPages++
|
||||
if (e.praying > 0) continue
|
||||
noPray++
|
||||
noPrayPeople += r.population || 0
|
||||
/* THE MOVE: need first, then size — the same order the country sheet uses, so a reader
|
||||
drilling from country to province is not handed two different rankings. */
|
||||
const rank = ((tiers[r.iso639_3] ?? r.tier ?? 0) === 0 ? 1e12 : 0) + (r.population || 0)
|
||||
if (rank > moveRank) {
|
||||
moveRank = rank
|
||||
move = { name: e.n || r.name, url: e.u, people: r.population || 0, has_nothing: (tiers[r.iso639_3] ?? r.tier ?? 0) === 0 }
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
country: c.country,
|
||||
iso3: c.iso3,
|
||||
|
|
@ -125,7 +185,20 @@ for (const c of COUNTRIES) {
|
|||
/* Named so a reader of the CSV can act without opening the map. */
|
||||
zero_access_examples: zero.sort((a, b) => (b.population || 0) - (a.population || 0))
|
||||
.slice(0, 3).map((r) => r.name).join('; '),
|
||||
ways_in: [...new Set(zero.map((r) => wayIn(r.iso639_3, r.iso3, r.name)).filter(Boolean))].join('; '),
|
||||
prayer_pages: prayPages,
|
||||
/* The URL comes from the prayer index, which took it from doxa.life's OWN listing (a slug is
|
||||
never constructed here: pray.doxa.life answers 200 for anything). */
|
||||
pray_first: move,
|
||||
nobody_praying_groups: noPray,
|
||||
nobody_praying_people: noPrayPeople,
|
||||
ways_in: local.length
|
||||
? local.map((l) => `${l.language} (${l.people.toLocaleString('en-US')} here)`).join('; ')
|
||||
: [...new Set(zero.map((r) => wayIn(r.iso639_3, r.iso3, r.name)).filter(Boolean))].join('; '),
|
||||
/* WHICH QUESTION THE WAY IN ANSWERED. Without this column the two kinds of answer are
|
||||
indistinguishable on the page, and a country-level answer read as a local one is exactly
|
||||
how Telugu ends up looking like advice about Arunachal Pradesh. */
|
||||
ways_in_basis: local.length ? 'in this province' : 'country-level — no local language here has both Scripture and film',
|
||||
ways_in_country: [...new Set(zero.map((r) => wayIn(r.iso639_3, r.iso3, r.name)).filter(Boolean))].join('; '),
|
||||
approximate: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -166,14 +239,15 @@ if (!assigned || assigned < 900) {
|
|||
china_note: 'China\'s ADM1 file carries 12 provinces, not 31+. Measured rather than ' +
|
||||
'assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the ' +
|
||||
'file is cut to the provinces where the unreached peoples are.',
|
||||
ways_in_are_country_level: 'The ways in are chosen for the COUNTRY, and going below the ' +
|
||||
'border is what exposes the cost of that. Arunachal Pradesh, in the far north-east, is ' +
|
||||
'offered TELUGU — a South Indian language — because the curated bridge table names it ' +
|
||||
'for those groups and a curated bridge outranks the computed one. India\'s computed ' +
|
||||
'default is Hindi (41%, official), which is defensible nationally and still wrong for ' +
|
||||
'much of the north-east. This table does not fix that: it makes it visible. Until a ' +
|
||||
'bridge can be chosen per province, read the ways-in column as "a language with ' +
|
||||
'Scripture and film somewhere in this country", not as a recommendation for this place.',
|
||||
ways_in: 'Chosen FOR THE PROVINCE where the province can answer: the languages of groups ' +
|
||||
'standing in it that already have Scripture AND film, ranked by the people here who ' +
|
||||
'already hold them. The column says which basis was used. This replaces a country-level ' +
|
||||
'answer that produced a measured absurdity — Arunachal Pradesh, in the far north-east, ' +
|
||||
'was offered TELUGU, because the curated bridge table is keyed by country and a curated ' +
|
||||
'bridge outranks a computed one. A province with no tier-2 language of its own still ' +
|
||||
'falls back to the country answer, and ways_in_basis says so rather than hiding it. ' +
|
||||
'It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a ' +
|
||||
'question for someone on the ground.',
|
||||
approximate: 'A province is assigned by point-in-polygon on the group\'s coordinate, and ' +
|
||||
'that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the ' +
|
||||
'roster even share a coordinate with another group. Near a border the point can fall on ' +
|
||||
|
|
@ -188,7 +262,12 @@ if (!assigned || assigned < 900) {
|
|||
{ title: 'Groups with nothing', field: 'zero_access_groups', type: 'number' },
|
||||
{ title: 'People with nothing', field: 'zero_access_people', type: 'number' },
|
||||
{ title: 'Largest with nothing', field: 'zero_access_examples', type: 'string' },
|
||||
{ title: 'Nobody praying', field: 'nobody_praying_groups', type: 'number' },
|
||||
{ title: 'Of those with a prayer page', field: 'prayer_pages', type: 'number' },
|
||||
{ title: 'People nobody is praying for', field: 'nobody_praying_people', type: 'number' },
|
||||
{ title: 'Ways in', field: 'ways_in', type: 'string' },
|
||||
{ title: 'Basis', field: 'ways_in_basis', type: 'string' },
|
||||
{ title: 'Ways in (country-level)', field: 'ways_in_country', type: 'string' },
|
||||
{ title: 'Placement approximate', field: 'approximate', type: 'string' },
|
||||
],
|
||||
rows,
|
||||
|
|
|
|||
|
|
@ -580,11 +580,32 @@ const DERIVED_AXES = {
|
|||
}
|
||||
// ONE resolver for both filter loops: a derived key runs its accessor, a real key reads the row.
|
||||
const rowAxisValue = (u, field) => (DERIVED_AXES[field] ? DERIVED_AXES[field](u) : u[field])
|
||||
// ★ A SELECTION MAY BE SEVERAL VALUES (SPEC-DASHBOARD-KIT D1), and this is the end of the wire
|
||||
// that makes the "Arabic" case a real narrowing instead of an honest abstention. The language tier
|
||||
// "Arabic" covers 23 ISO codes, so no single value expresses it; until now the composite had to
|
||||
// send nothing and leave the axis unfiltered rather than show a fraction of the selection or an
|
||||
// empty grid. A comma-separated list is accepted here and matched as OR-within-the-axis, which is
|
||||
// exactly what the bus's facetMatches() does at the other end — one rule, two implementations,
|
||||
// and they have to stay the same shape.
|
||||
// ★ THE SEPARATOR IS `|`, AND IT IS NOT `,` FOR A MEASURED REASON. A separator that can also occur
|
||||
// inside a value is not a separator — the rule the URL hash already states one file over. The first
|
||||
// version of this split on `,` and BROKE LABEL MATCHING, because 40% of the live language labels
|
||||
// are comma-inverted ("Arabic, Mesopotamian"): the selection was chopped into "Arabic" and
|
||||
// "Mesopotamian" and matched neither. Caught by check:filter-multivalue's own label assertion
|
||||
// before it left this machine, which is the entire point of asserting a behaviour you are keeping
|
||||
// rather than only the one you are adding. Measured on the 864 live label/value pairs: `,` appears
|
||||
// in 120 of them, `|` in none.
|
||||
// A HOST STILL SENDS ONE STRING: an attribute is a string, so a list arrives as "apd|acm|ayl", and
|
||||
// a single value with no separator takes the identical path it always did.
|
||||
function valueMatches(vl, selection) {
|
||||
if (!vl) return false
|
||||
const want = norm(selection?.value)
|
||||
if (!want) return false
|
||||
return norm(vl.value) === want || norm(vl.label) === want
|
||||
const raw = selection?.value
|
||||
if (raw === null || raw === undefined || raw === '') return false
|
||||
const wants = (Array.isArray(raw) ? raw : String(raw).split('|'))
|
||||
.map((v) => norm(v)).filter(Boolean)
|
||||
if (!wants.length) return false
|
||||
const value = norm(vl.value), label = norm(vl.label)
|
||||
return wants.some((want) => value === want || label === want)
|
||||
}
|
||||
|
||||
function applyDropdownFilters(list) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
import { CDN, pinElement } from '@map/constants/cdn.js'
|
||||
import { loadPinnedUmd } from '@map/utils/loadPinnedUmd.js'
|
||||
import { setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { providerFor, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
let base = './assets'
|
||||
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
|
||||
export function dataUrl(file) { return base + '/pplr/' + file }
|
||||
|
|
@ -112,7 +112,9 @@ export function exposeMap(map) {
|
|||
let layers = []
|
||||
try { layers = map.getStyle()?.layers || [] } catch (_) {}
|
||||
return {
|
||||
engine: window.maplibregl && window.maplibregl.Map && map instanceof window.maplibregl.Map ? 'maplibre' : 'mapbox',
|
||||
// C3: ask the library, not the globals. `|| 'mapbox'` keeps the ORIGINAL fall-through for a map
|
||||
// that is neither (stub, destroyed, neither global loaded) -- see providerFor's own note.
|
||||
engine: providerFor(map) || 'mapbox',
|
||||
// 'load' may already have fired before this listener was attached; a loaded style says so too
|
||||
loaded: loaded || !!(map.isStyleLoaded && map.isStyleLoaded()),
|
||||
styleLayers: layers.length,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { tStatic } from '@map/i18n/index.js'
|
|||
const T = (key, en) => tStatic(key) || en
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useDashboardShell, fmt } from './src/composables/useDashboardShell.js'
|
||||
import { useDashboardLayout } from './src/composables/useDashboardLayout.js'
|
||||
import { useDashboardLayout } from '@map/composables/useDashboardLayout.js'
|
||||
import {
|
||||
DashboardTopBar,
|
||||
DashboardResearchFilters,
|
||||
|
|
@ -253,7 +253,13 @@ const { startFacetResize, startRailResize, startTableResize, startShellResize, o
|
|||
// by the stylesheet and no longer drives any layout (see the pageScroll note above); the
|
||||
// composable needs to know so the table-axis handle can drive --rd-map-h instead.
|
||||
pageScroll,
|
||||
storageKey: 'rd-dashboard-layout',
|
||||
// ★ NAMESPACED PER DASHBOARD (kit landing 2). This was the single constant every dashboard
|
||||
// shared, which was harmless while there was only one and becomes a collision the moment a page
|
||||
// carries two — each would read and write the other's divider positions. config.id is the
|
||||
// profile's own identity, so a second dashboard on the same page gets its own drawer without its
|
||||
// author having to know this key exists. useDashboardLayout reads the old shared key once, so no
|
||||
// reader loses the layout they had.
|
||||
storageKey: 'rd-dashboard-layout:' + (mergedConfig.value?.id || 'default'),
|
||||
// MUST mirror the `:host` --facet-w/--rail-w defaults, or reset() would snap the rails to a
|
||||
// different balance than a fresh load shows. Tree wider than charts — see the rail-balance note.
|
||||
// mapH mirrors the page-scroll CSS fallback 40svh (~360px at a 900px viewport — see
|
||||
|
|
|
|||
|
|
@ -48,18 +48,38 @@
|
|||
* reads the URL first) — the engine is Mapbox, it simply has no key. A gate that fails a
|
||||
* product for a key the gate did not provide is measuring its own environment.
|
||||
* It now takes MAPBOX_TOKEN from the env (or vite.doxa-maps-build-config.json `token`, the
|
||||
* same ladder check:experimental-shelf uses) and injects it as window.MAP_TOKEN before
|
||||
* navigation, the way check:auto-engine and check:a11y-button-names do. With no token the
|
||||
* same ladder check:experimental-shelf uses) and supplies it on BOTH documented rungs — the
|
||||
* host global (window.MAP_TOKEN, injected before navigation the way check:auto-engine does)
|
||||
* and the ?tk= URL param — as two separate cases. See the CASES block below for why two.
|
||||
* With no token the
|
||||
* mapbox half is NOT MEASURED — reported as a SKIP line so run-parallel prints ⊘ rather than
|
||||
* counting it as a pass, because an always-taken skip is indistinguishable from an unwired
|
||||
* gate unless the runner says so.
|
||||
*
|
||||
* ★ WHY THE MAP PROBE ASKS THE ENGINE AND NOT THE DOM (2026-09-26, the second correction in a
|
||||
* day). This gate reported "NO map constructed" on a leaf where rega then measured 1 Mapbox map, 2
|
||||
* GL canvases and 3 search inputs, on BOTH token rungs. The product was never at fault; the
|
||||
* detection was. A DOM walk for a canvas is a chain of assumptions — every shadow root open, the
|
||||
* canvas carrying the class expected, existing by the time it is looked for — and when any link
|
||||
* breaks it reports ABSENCE, which is indistinguishable from a broken product. It now hooks the
|
||||
* engine's own Map.prototype.setStyle through both globals, which cannot be wrong about the DOM
|
||||
* because it never looks at it, and widens the bar selector to what a reader would call a search
|
||||
* box. Both borrowed from rega's tokrung.mjs.
|
||||
*
|
||||
* THE LESSON, recorded because it is the second time in two days that a probe accused a working
|
||||
* product: a measurement that yields ZERO when its own assumption is wrong will always blame the
|
||||
* thing it is measuring. Where a positive control exists that measures correctly, copy the control.
|
||||
*
|
||||
* ASSERTIONS, on the built tree:
|
||||
* maplibre ZERO requests to api.mapbox.com. (Measured with or without a token.)
|
||||
* If a bar is present it must produce suggestions; if it is absent, that is the
|
||||
* documented contract and is recorded as such.
|
||||
* mapbox the bar is PRESENT and produces suggestions. Requires a token; without one this
|
||||
* half is skipped, never failed. Mapbox traffic is expected here and not asserted on.
|
||||
* mapbox TWICE, once per token rung: a map CONSTRUCTS, the bar is PRESENT, and typing
|
||||
* produces suggestions. Requires a token; without one this half is skipped, never
|
||||
* failed. Mapbox traffic is expected here and not asserted on — but rejected
|
||||
* RESPONSES are recorded, because "21 requests and no map" is otherwise ambiguous
|
||||
* between a token never seen, a token refused, and a map that failed for its own
|
||||
* reasons, and those three have different owners.
|
||||
*
|
||||
* NEVER PRINTS THE TOKEN. Only its presence is reported.
|
||||
*
|
||||
|
|
@ -116,18 +136,71 @@ const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '-
|
|||
const fails = []
|
||||
const lines = []
|
||||
try {
|
||||
// The mapbox half needs a key. Measure maplibre either way — it is the headline claim and it is
|
||||
// fully measurable keyless, which is the whole point of the engine.
|
||||
const engines = TOKEN ? ['maplibre', 'mapbox'] : ['maplibre']
|
||||
for (const engine of engines) {
|
||||
// ★ THE MAPBOX HALF IS MEASURED ON BOTH TOKEN RUNGS, AND THAT IS A DELIBERATE EXPERIMENT.
|
||||
// rega, gates:post at 1a761741 WITH a token: the mapbox half reported "NO map constructed", 21
|
||||
// requests to api.mapbox.com, 0 bars — while rega's own probe of THIS leaf at
|
||||
// ?provider=mapbox&tk=<same token> constructed a Mapbox map. The suggested cause was that the
|
||||
// generated leaf overwrites the window.MAP_TOKEN this gate sets in addInitScript.
|
||||
//
|
||||
// IT DOES NOT. research-map/index.html reads it at line 70 (`HOST_TOKEN = window.MAP_TOKEN`) and
|
||||
// every assignment after it — lines 71, 109, 150 — is guarded by `!window.MAP_TOKEN`, so a
|
||||
// host-set value is read and never replaced. studio read the same lines independently. The
|
||||
// hypothesis is the obvious one and the source does not support it.
|
||||
//
|
||||
// So rather than switch channels on a mechanism nobody can point at, the gate now measures BOTH:
|
||||
// the host global that partners use, and the ?tk= URL rung. If both construct, the earlier
|
||||
// failure was this gate's and is gone. If ONLY ?tk= constructs, that is a PRODUCT defect on the
|
||||
// host-global rung — the one an embedding partner reaches for — and the gate will say so by
|
||||
// name instead of leaving a one-line change to look like a fix.
|
||||
//
|
||||
// The maplibre case carries NO token by either route: resolveMapProvider's `auto` rung reads a
|
||||
// token as "use Mapbox", and a present token is exactly the variable this gate holds at zero.
|
||||
const CASES = [{ id: 'maplibre', provider: 'maplibre', rung: 'none' }]
|
||||
if (TOKEN) CASES.push(
|
||||
{ id: 'mapbox/window.MAP_TOKEN', provider: 'mapbox', rung: 'global' },
|
||||
{ id: 'mapbox/?tk=', provider: 'mapbox', rung: 'url' },
|
||||
)
|
||||
for (const c of CASES) {
|
||||
const engine = c.provider
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
const mapboxCalls = []
|
||||
// ★ STATUSES TOO, not just counts. "21 requests and no map" is ambiguous between a token that
|
||||
// was never seen, one that was REJECTED, and a map that failed for an unrelated reason — and
|
||||
// the three have different owners. A 401 says the key, not the code.
|
||||
const mapboxBad = []
|
||||
page.on('request', (r) => { const u = r.url(); if (u.includes('api.mapbox.com')) mapboxCalls.push(u) })
|
||||
// ★ ONLY under mapbox. Injecting a token into the maplibre run would change what is being
|
||||
// measured: resolveMapProvider's `auto` rung reads a token as "use Mapbox", and even with an
|
||||
// explicit ?provider=maplibre a present token is a variable this gate exists to hold at zero.
|
||||
if (engine === 'mapbox') await page.addInitScript((tk) => { window.MAP_TOKEN = tk }, TOKEN)
|
||||
await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||
page.on('response', (r) => { if (r.url().includes('api.mapbox.com') && r.status() >= 400) mapboxBad.push(r.status()) })
|
||||
if (c.rung === 'global') await page.addInitScript((tk) => { window.MAP_TOKEN = tk }, TOKEN)
|
||||
// ★ ASK THE ENGINE, NOT THE DOM, WHETHER A MAP EXISTS. See the header note; borrowed from
|
||||
// rega's tokrung.mjs, which measured this leaf correctly while this gate did not. Both globals
|
||||
// are hooked because the leaf loads only the engine its provider asks for.
|
||||
await page.addInitScript(() => {
|
||||
window.__mapsBuilt = { mapbox: 0, maplibre: 0 }
|
||||
const hook = (name) => {
|
||||
let real
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: true,
|
||||
get: () => real,
|
||||
set: (gl) => {
|
||||
real = gl
|
||||
try {
|
||||
const Ctor = gl && gl.Map
|
||||
if (Ctor && !Ctor.__counted) {
|
||||
Ctor.__counted = true
|
||||
const setStyle = Ctor.prototype.setStyle
|
||||
Ctor.prototype.setStyle = function (...a) {
|
||||
window.__mapsBuilt[name === 'mapboxgl' ? 'mapbox' : 'maplibre']++
|
||||
return setStyle.apply(this, a)
|
||||
}
|
||||
}
|
||||
} catch (_) { /* a hook must never be the reason a page fails */ }
|
||||
},
|
||||
})
|
||||
}
|
||||
hook('mapboxgl'); hook('maplibregl')
|
||||
})
|
||||
const url = `${LEAF}?provider=${engine}` + (c.rung === 'url' ? `&tk=${encodeURIComponent(TOKEN)}` : '')
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||
await page.waitForTimeout(20000)
|
||||
|
||||
const search = await page.evaluate(async () => {
|
||||
|
|
@ -137,14 +210,26 @@ try {
|
|||
if (!root || seen.has(root)) return
|
||||
seen.add(root)
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.matches && el.matches('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')) hits.push(el)
|
||||
// ★ WIDER THAN THE PLUGIN'S OWN CLASS. This matched only
|
||||
// input.mapboxgl-ctrl-geocoder--input and reported 0 bars on a leaf carrying three
|
||||
// visible search inputs. Whether the miss was the class or the shadow walk cannot be told
|
||||
// from here, and that is the point: a selector narrow enough that one wrong assumption
|
||||
// yields ZERO accuses the product of being broken when it is only wrong about a name.
|
||||
if (el.matches && el.matches(
|
||||
'input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input,'
|
||||
+ ' .mapboxgl-ctrl-geocoder input, .maplibregl-ctrl-geocoder input,'
|
||||
+ ' input[type="search"], input[placeholder*="Search" i]')) hits.push(el)
|
||||
if (el.shadowRoot) walk(el.shadowRoot)
|
||||
}
|
||||
}
|
||||
// Did an engine actually construct a map? A canvas is the one artefact both engines share,
|
||||
// and it tells "the bar was removed" apart from "there was never a map to hang it on".
|
||||
let mapReady = false
|
||||
for (const root of seen) { if (root.querySelector && root.querySelector('canvas.mapboxgl-canvas, canvas.maplibregl-canvas, .mapboxgl-map canvas, .maplibregl-map canvas')) { mapReady = true; break } }
|
||||
// The engine hook is the answer; the canvas walk stays only as a second opinion, so an engine
|
||||
// that stops routing through setStyle degrades to the old behaviour instead of accusing the
|
||||
// product.
|
||||
const built = window.__mapsBuilt || {}
|
||||
let mapReady = !!(built.mapbox || built.maplibre)
|
||||
if (!mapReady) for (const root of seen) { if (root.querySelector && root.querySelector('canvas.mapboxgl-canvas, canvas.maplibregl-canvas, .mapboxgl-map canvas, .maplibregl-map canvas')) { mapReady = true; break } }
|
||||
if (!hits.length) return { bars: 0, suggestions: 0, mapReady }
|
||||
const input = hits[0]
|
||||
input.focus()
|
||||
|
|
@ -160,13 +245,14 @@ try {
|
|||
})
|
||||
await page.close()
|
||||
|
||||
lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}${engine === 'mapbox' ? ' · token: supplied' : ''}`)
|
||||
const bad = mapboxBad.length ? ` · REJECTED responses: ${mapboxBad.length} (${[...new Set(mapboxBad)].join(', ')})` : ''
|
||||
lines.push(` · ${c.id.padEnd(22)} api.mapbox.com requests: ${mapboxCalls.length} · map: ${search.mapReady ? 'yes' : 'NO'} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}${bad}`)
|
||||
|
||||
if (engine === 'maplibre') {
|
||||
// THE HEADLINE. One request sends the visitor's IP to Mapbox and breaks the keyless promise,
|
||||
// whatever the page looks like afterwards.
|
||||
if (mapboxCalls.length) {
|
||||
fails.push(`under maplibre the leaf made ${mapboxCalls.length} request(s) to api.mapbox.com:`)
|
||||
fails.push(`[${c.id}] the leaf made ${mapboxCalls.length} request(s) to api.mapbox.com:`)
|
||||
for (const u of [...new Set(mapboxCalls)].slice(0, 8)) fails.push(` ${u.slice(0, 150)}`)
|
||||
}
|
||||
// A bar is OPTIONAL here and its absence is the documented contract (see the header). What is
|
||||
|
|
@ -182,11 +268,16 @@ try {
|
|||
// `mapReady` separates "the bar was removed" from "the map never constructed", which are
|
||||
// different bugs with the same symptom — and the second one is how this gate misread itself.
|
||||
if (!search.bars) {
|
||||
fails.push(search.mapReady
|
||||
? 'under mapbox the map constructed but rendered NO search bar — hasGeocoder or the GeocoderComponent mount is broken.'
|
||||
: 'under mapbox NO map constructed (so the bar\'s v-if could never fire). The token was supplied, so this is the product or the leaf, not a missing key.')
|
||||
const rung = c.rung === 'global' ? 'the HOST GLOBAL rung (window.MAP_TOKEN, what an embedding partner sets)' : 'the ?tk= URL rung'
|
||||
if (!search.mapReady) {
|
||||
fails.push(mapboxBad.length
|
||||
? `[${c.id}] NO map constructed and ${mapboxBad.length} api.mapbox.com response(s) came back ${[...new Set(mapboxBad)].join('/')} — the token reached Mapbox and was REFUSED. That is the key, not the code.`
|
||||
: `[${c.id}] NO map constructed, via ${rung}, with a token supplied and no rejected responses. The leaf never overwrites a host-set window.MAP_TOKEN (index.html:70 reads it, :71/:109/:150 are all guarded), so if the other rung constructs and this one does not, this is a PRODUCT defect on the rung partners use — not this gate.`)
|
||||
} else {
|
||||
fails.push(`[${c.id}] the map constructed but rendered NO search bar — hasGeocoder or the GeocoderComponent mount is broken.`)
|
||||
}
|
||||
} else if (!search.suggestions) {
|
||||
fails.push('under mapbox the search bar rendered but typing produced no suggestions.')
|
||||
fails.push(`[${c.id}] the search bar rendered but typing produced no suggestions.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -210,4 +301,4 @@ if (!TOKEN) {
|
|||
console.log(' Set MAPBOX_TOKEN in the environment, or `token` in vite.doxa-maps-build-config.json, to measure it.')
|
||||
process.exit(0)
|
||||
}
|
||||
console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times, and Mapbox still searches.`)
|
||||
console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times, and Mapbox searches on both token rungs.`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-layout-key-namespaced.mjs — two dashboards on one page must not share a saved layout.
|
||||
*
|
||||
* useDashboardLayout persists the four divider positions to localStorage. Until kit landing 2 the
|
||||
* key was a single constant, so every dashboard wrote to the same slot. That was invisible while
|
||||
* there was exactly one dashboard — and SHARING THE ENGINE IS PRECISELY WHAT MAKES A SECOND ONE
|
||||
* POSSIBLE, which is why SPEC-DASHBOARD-KIT section 8 named it as landing 2's risk and why the fix
|
||||
* belongs in the commit that shares the engine rather than in the one that first trips over it.
|
||||
*
|
||||
* The failure it prevents is quiet and confusing rather than loud: drag a divider in dashboard A,
|
||||
* reload, and dashboard B has moved. Neither is wrong on its own, nothing errors, and the reader
|
||||
* has no reason to suspect the other element on the page.
|
||||
*
|
||||
* WHAT IT CHECKS, statically — no DOM, no browser:
|
||||
* A. the shell does not pass a BARE STRING as storageKey; the key must be derived from something
|
||||
* per-dashboard (the profile's own `id`).
|
||||
* B. two different dashboard ids produce two different keys, by evaluating the expression the
|
||||
* shell actually passes rather than a retyped copy of it.
|
||||
* C. the legacy shared key is READ and never WRITTEN — namespacing must not silently reset a
|
||||
* reader's saved layout, and a migration that also deletes cannot be rolled back.
|
||||
*
|
||||
* NEGATIVE-TESTED: putting a bare literal back fails A; making the expression ignore the id fails
|
||||
* B; adding a setItem on the legacy key fails C.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TAG = 'LAYOUT-KEY-NAMESPACED'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const SHELL = join(ROOT, 'plugin-designs/research-dashboard/research-dashboard.vue')
|
||||
const ENGINE = join(ROOT, 'library/composables/useDashboardLayout.js')
|
||||
const fails = []
|
||||
const notes = []
|
||||
|
||||
if (!existsSync(ENGINE)) { console.log(`${TAG}: SKIP — no ${ENGINE.replace(ROOT + '/', '')} (kit landing 2 not applied).`); process.exit(0) }
|
||||
|
||||
// ── A + B. the key the shell passes ──────────────────────────────────────────────────────────
|
||||
{
|
||||
const src = readFileSync(SHELL, 'utf8')
|
||||
const m = /storageKey:\s*([^\n]+?),\s*$/m.exec(src)
|
||||
if (!m) {
|
||||
fails.push('A research-dashboard.vue passes no storageKey — the engine would fall back to its shared default and every dashboard on the page would collide.')
|
||||
} else {
|
||||
const expr = m[1].trim()
|
||||
if (/^'[^']*'$/.test(expr) || /^"[^"]*"$/.test(expr)) {
|
||||
fails.push(`A storageKey is the bare literal ${expr} — a second dashboard on the same page reads and writes this one's layout.`)
|
||||
} else {
|
||||
// Evaluate the REAL expression with two different configs rather than retyping the rule: a
|
||||
// gate carrying its own copy of the thing it checks passes while the product is broken.
|
||||
try {
|
||||
const keyFor = new Function('mergedConfig', `return ${expr}`)
|
||||
const a = keyFor({ value: { id: 'doxa-research-dashboard' } })
|
||||
const b = keyFor({ value: { id: 'gospel-internet' } })
|
||||
const none = keyFor({ value: {} })
|
||||
if (a === b) fails.push(`B two different dashboard ids produce the SAME key (${JSON.stringify(a)}) — the expression does not actually vary with the dashboard.`)
|
||||
if (!String(a).includes('doxa-research-dashboard')) fails.push(`B the key ${JSON.stringify(a)} does not carry the dashboard id, so it cannot be unique per dashboard.`)
|
||||
if (!none) fails.push('B a config with no id yields an empty key — a dashboard that forgets its id must still get a usable slot, not localStorage[""].')
|
||||
if (!fails.some((f) => f.startsWith('B'))) notes.push(`key: ${JSON.stringify(a)} vs ${JSON.stringify(b)} — distinct per dashboard`)
|
||||
} catch (e) {
|
||||
fails.push(`B could not evaluate the storageKey expression (${e.message}) — it may be correct, but this gate cannot prove it, which is the same as not being checked.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── C. the legacy key is read, never written ─────────────────────────────────────────────────
|
||||
{
|
||||
const src = readFileSync(ENGINE, 'utf8')
|
||||
const legacy = /const LEGACY_SHARED_KEY = '([^']+)'/.exec(src)
|
||||
if (!legacy) {
|
||||
fails.push('C useDashboardLayout no longer reads the pre-namespacing key — every reader who had dragged a divider silently loses their layout on upgrade.')
|
||||
} else {
|
||||
const name = 'LEGACY_SHARED_KEY'
|
||||
if (!new RegExp(`getItem\\(\\s*${name}\\s*\\)`).test(src)) fails.push(`C ${name} is declared but never read, so it protects nobody.`)
|
||||
if (new RegExp(`(setItem|removeItem)\\(\\s*${name}`).test(src)) {
|
||||
fails.push(`C ${name} is WRITTEN or REMOVED. The read-through exists so namespacing does not reset a saved layout; a migration that also deletes cannot be rolled back.`)
|
||||
}
|
||||
if (!fails.some((f) => f.startsWith('C'))) notes.push(`legacy key ${JSON.stringify(legacy[1])}: read once, never written`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(' · ' + n)
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG}: FAIL`)
|
||||
for (const f of fails) console.error(' ✗ ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — each dashboard owns its saved layout, and upgrading does not discard one.`)
|
||||
|
|
@ -56,7 +56,7 @@ function rowsExcluding(exclude) {
|
|||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
}
|
||||
|
||||
|
|
@ -89,9 +89,9 @@ const charts = computed(() => (cfg.charts || []).map((c) => {
|
|||
: buildDonut(agg, 132, 0.58, c.facet, overrides),
|
||||
}
|
||||
}))
|
||||
const isActive = (facet, key) => filterSet[facet] === key
|
||||
const isActive = (facet, key) => bus.isFilterActive(facet, key)
|
||||
// true when this chart's own facet has an active selection → drives the pop-out / dim-others state.
|
||||
const hasSelection = (facet) => filterSet[facet] != null
|
||||
const hasSelection = (facet) => filterSet[facet] != null // any selection, scalar or array
|
||||
|
||||
function pick(facet, key) {
|
||||
if (!key || key === 'Other' || !bus) return
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ function rowsExcluding(exclude) {
|
|||
const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k))
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
// The rail prints the COUNTS a reader acts on before clicking, so it above all must use the
|
||||
// same predicate the map and table use — a rail still comparing with === would show counts for
|
||||
// a scope the other panels no longer render. See D1 in useSelectionBus.js.
|
||||
const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
}
|
||||
|
||||
|
|
@ -125,14 +128,14 @@ function isExpandedRaw(facet, value) {
|
|||
const k = nodeKey(facet, value)
|
||||
// undefined = never touched → fall back to "expanded iff this node is the active filter",
|
||||
// so an active branch still opens itself exactly as it always did.
|
||||
return k in expanded ? expanded[k] : filterSet[facet] === value
|
||||
return k in expanded ? expanded[k] : bus.isFilterActive(facet, value)
|
||||
}
|
||||
|
||||
function toRows(agg, facet, level) {
|
||||
const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1
|
||||
return agg.map((d) => ({
|
||||
facet, value: d.key, label: d.key, count: d.value, frac: d.value / max, level,
|
||||
other: !!d.other, active: !d.other && filterSet[facet] === d.key,
|
||||
other: !!d.other, active: !d.other && bus.isFilterActive(facet, d.key),
|
||||
// Carried per-ROW rather than looked up in the template, so the zero-row baseline snapshot
|
||||
// (which spreads a row object) keeps the mark when a row is re-admitted.
|
||||
dashboardOnly: DASHBOARD_ONLY.has(facet),
|
||||
|
|
@ -390,7 +393,7 @@ const scopeCount = computed(() => {
|
|||
const rows = dash?.rows?.value || []
|
||||
const keys = Object.keys(filterSet)
|
||||
if (!keys.length) return rows.length
|
||||
return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })).length
|
||||
return rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC)).length
|
||||
})
|
||||
|
||||
// each rendered row already carries its own facet key (data-facet/data-value).
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const filteredRows = computed(() => {
|
|||
const keys = Object.keys(filterSet)
|
||||
const hasText = !!(bus?.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
})
|
||||
// Geo join is config-driven: geoFacet selects the facet whose `geoIso` accessor yields the ISO-3 join
|
||||
|
|
|
|||
|
|
@ -157,7 +157,12 @@ const filters = computed(() => dropdownKeys.value.map((key) => ({
|
|||
name: key,
|
||||
label: FACETS.value[key]?.label || key,
|
||||
options: optionsFor(key),
|
||||
value: filterSet[key] || '',
|
||||
// ★ A DROPDOWN CANNOT SHOW A MULTI-VALUE SELECTION, SO IT SHOWS NONE (D1).
|
||||
// Picking the first of several would put a tick beside one option while the board is narrowed by
|
||||
// three — a control stating something false about the state it belongs to, which is worse than a
|
||||
// control stating nothing. The CHIP still names every value, so the selection is never invisible:
|
||||
// it is simply reported by the surface that can report it honestly.
|
||||
value: Array.isArray(filterSet[key]) ? '' : (filterSet[key] || ''),
|
||||
})))
|
||||
|
||||
// Chips derived from the live filterSet. For THIS component's own dropdown axes label === value
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ export const EXPORT_FIELDS = [
|
|||
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
|
||||
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
|
||||
*/
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null }) {
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null,
|
||||
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
|
||||
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
|
||||
// slug -> wide API record, filled by the lazy all-fields fetch below.
|
||||
let wideStore = null
|
||||
let widePromise = null
|
||||
|
|
@ -117,7 +119,10 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
}
|
||||
const keys = Object.keys(filterSet)
|
||||
if (keys.length) {
|
||||
return (rows.value || []).filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true }))
|
||||
// The bus's own predicate, handed in by the shell — the exporter must select the same rows
|
||||
// the reader is looking at, and a private copy of the rule is how an export starts
|
||||
// disagreeing with the screen it was taken from.
|
||||
return (rows.value || []).filter((r) => rowMatches(r, FACET_ACC))
|
||||
}
|
||||
return rows.value || []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,518 +1,11 @@
|
|||
/**
|
||||
* useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
|
||||
* useDashboardLayout.js — RE-EXPORT SHIM (dashboard-kit landing 2).
|
||||
*
|
||||
* A dashboard shell (see plugin-designs/doxa-research-dashboards/research-dashboards.vue) is a CSS
|
||||
* grid whose three flexible tracks are driven by CSS custom properties:
|
||||
* --facet-w (left facet-rail column width) default 296px
|
||||
* --rail-w (right charts-rail column width) default 296px — equals --facet-w, so the map column sits centred
|
||||
* --table-h (bottom table row height) default 34% (map above it is the 1fr hero)
|
||||
* The engine now lives at library/composables/useDashboardLayout.js. Same one-cycle contract as
|
||||
* landing 1's bus shim: every importer keeps working unchanged while the move is verified on its
|
||||
* own, and this file is deleted in the next landing.
|
||||
*
|
||||
* This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge),
|
||||
* clamps each to a sane min/max, writes the vars onto the shell root element as INLINE custom
|
||||
* properties, PERSISTS the chosen layout to localStorage on every drag-end, RESTORES it on init,
|
||||
* and offers reset() to clear the key and snap back to the exact defaults.
|
||||
*
|
||||
* SHELL vs. TABLE axis (page-scroll mode only — BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER):
|
||||
* Two DIFFERENT questions look similar and must stay answered by two DIFFERENT handles.
|
||||
* "table" axis = the MAP/TABLE SPLIT. In page-scroll mode it redistributes height BETWEEN
|
||||
* the map and the table; the sum of the two (the element's total height)
|
||||
* does NOT change. This is the EXISTING map/table divider
|
||||
* (.rd-rz-table in research-dashboard.vue), unchanged in grid-mode.
|
||||
* "shell" axis = the ELEMENT'S OWN HEIGHT (page-scroll mode only) — a bar at the shell's
|
||||
* BOTTOM edge, the boundary between the whole research-dashboard element and
|
||||
* whatever a host stacks below it (e.g. the people-groups grid in
|
||||
* dashboard-plus-grid.vue). Dragging it changes --rd-shell-h, the total map+
|
||||
* table budget; the map/table SPLIT within that budget is untouched.
|
||||
* --rd-shell-h is the new independent variable; --rd-map-h is now the map's share of it, and
|
||||
* the table's share is derived as shellH - mapH (see the CSS in research-dashboard.vue). Before
|
||||
* this, --rd-map-h alone was BOTH "the map/table split" AND "the element's total height" — one
|
||||
* knob answering two questions, which is why dragging the map/table divider used to resize the
|
||||
* whole element as a side effect. Driver: "you improperly bound that control to the separator
|
||||
* line between the map and the table... that's not the request I was making to resize the
|
||||
* height of the page."
|
||||
*
|
||||
* COMPOSITION with the collapse-table fix (do NOT regress):
|
||||
* The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule
|
||||
* `.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0 }` comes LATER in source
|
||||
* order (same specificity) so COLLAPSE WINS over --table-h. Because we only touch the CSS var
|
||||
* (never the collapsed row template), collapsing hides the table regardless of a dragged height,
|
||||
* and expanding restores the user's dragged --table-h. The center map is 1fr and already
|
||||
* self-resizes via DashboardMapPanel's ResizeObserver → map.resize(); we add no second resize.
|
||||
*
|
||||
* NON-REGRESSION with the responsive slim rails:
|
||||
* `.rd-shell.rd-slim-rail{ --rail-w:228px }` / `.rd-shell.rd-slim-facet{ --facet-w:250px }` are
|
||||
* medium-width niceties set via CLASS. Inline styles always beat class rules, so we apply inline
|
||||
* vars ONLY once the layout is customized (restored-from-storage or dragged). An untouched
|
||||
* dashboard writes no inline vars → the slim classes and the exact CSS defaults are preserved.
|
||||
* reset() removes the inline vars entirely, fully handing control back to the stylesheet.
|
||||
*
|
||||
* DESKTOP-ONLY: every drag is gated on `!isNarrow`; narrow/mobile never resizes or persists.
|
||||
*
|
||||
* Library-parameterizable — the profile's identity (defaults + storage key) lives in options, not
|
||||
* here. Any dashboard grid exposing the same three vars can reuse this unchanged.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import('vue').Ref<HTMLElement|null>} options.shellRef ref holding the .rd-shell root el
|
||||
* @param {object=} options.bus selection bus; when given, listens bus.on('resetLayout', reset)
|
||||
* @param {import('vue').Ref<boolean>|boolean=} options.isNarrow desktop gate (true = mobile/narrow)
|
||||
* @param {import('vue').Ref<boolean>|boolean=} options.pageScroll when true, the table axis drives
|
||||
* --rd-map-h (px) instead of --table-h — see the PAGE-SCROLL AXIS SWITCH note below.
|
||||
* @param {string=} options.storageKey localStorage key (default 'rd-dashboard-layout')
|
||||
* @param {{facetW?:number, railW?:number, tableH?:string, mapH?:number, shellH?:number}=} options.defaults default track sizes
|
||||
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number], mapH?:[number,number], shellH?:[number,number]}=} options.clamps
|
||||
* min/max — facetW/railW/mapH/shellH in px, tableH in percent-of-shell-height
|
||||
*
|
||||
* @returns {{
|
||||
* facetW: import('vue').Ref<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
|
||||
* mapH: import('vue').Ref<number>, shellH: import('vue').Ref<number>,
|
||||
* startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void,
|
||||
* startTableResize:(e:PointerEvent)=>void, startShellResize:(e:PointerEvent)=>void,
|
||||
* reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>,
|
||||
* stepResize:(axis:'facet'|'rail'|'table'|'shell', dir:-1|1, toExtreme?:boolean)=>void,
|
||||
* onResizeKeydown:(axis:'facet'|'rail'|'table'|'shell', e:KeyboardEvent)=>void
|
||||
* }}
|
||||
* It re-exports; it does not duplicate. A second copy would drift silently while both halves kept
|
||||
* passing their gates.
|
||||
*/
|
||||
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
// Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue.
|
||||
// These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them.
|
||||
// mapH mirrors the CSS fallback 40svh (~360px at a 900px viewport — see the page-scroll note
|
||||
// below); it is only ever WRITTEN once the user drags, so an untouched embed stays on the
|
||||
// viewport-relative svh default and keeps resizing with the window, exactly like every other var.
|
||||
// ★ 58svh (522px) was the ORIGINAL default and left the page-scroll band taller than one desktop
|
||||
// viewport (BUG-DASHBOARD-BAND-GROWS-ENORMOUS: chrome 137px + map 522px + table 552px = 1211px
|
||||
// against 900px). 40svh is the value that makes chrome + map(X) + table(X + bar-height) fit <=900px.
|
||||
// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table
|
||||
// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split;
|
||||
// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above).
|
||||
// ★ shellH 720 -> 602 so the table OPENS at seven data rows (Driver 2026-09-23: "the table needs to
|
||||
// only show SEVEN rows by DEFAULT height"). 602 = map 360 + bar 32 + 7 rows x 26 + foot 28.
|
||||
// ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped
|
||||
// the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes
|
||||
// --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not
|
||||
// be reached by any gesture. The Driver lost BOTH resizes at once and said so: "I didn't want the
|
||||
// table set to 7. Only the table needs to be resizable. We've set it to where I can't even resize."
|
||||
// Setting the STARTING VALUE instead leaves the derivation intact: the keyhole stays
|
||||
// shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is
|
||||
// merely where it opens.
|
||||
// ★ RECLAIM PASS 2 (Driver 2026-09-23: "it is a priority to reclaim height... so I can see half
|
||||
// of the first row of the people groups grid"). mapH 360 -> 300 and shellH follows the same
|
||||
// arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table
|
||||
// promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING
|
||||
// MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a
|
||||
// shorter map shows MORE world, not less.
|
||||
// shellH 502 -> 542 (Driver 2026-09-23: "the dashboard should be taller by about 40px"). The
|
||||
// extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot);
|
||||
// the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in
|
||||
// research-dashboard.vue kept byte-equal at 542.
|
||||
// mapH 260 -> 300 (Driver 2026-09-23 follow-up: the +40px was to be "added to the map -- map is
|
||||
// 40px taller display room"). shellH stays 542, so the table's derived share returns to its
|
||||
// pre-+40 height and the whole gain lands in the map row.
|
||||
// +40px again (Driver 2026-09-23 second raise: "go taller with the dashboard by another 40px,
|
||||
// add that to the height of the map"): shellH 542 -> 582 and mapH 300 -> 340 move together, so
|
||||
// the whole gain lands in the map row and the table's derived share stays put.
|
||||
// Driver fine-tune 2026-09-23: "almost perfect height -- keep the map the same height, but
|
||||
// decrease the height of the dashboard by about 20px". Map stays 340; the 20px comes out of
|
||||
// the table's derived share (shell - map - bar - foot).
|
||||
const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 340, shellH: 562 }
|
||||
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] }
|
||||
|
||||
// Mirrors the CSS fallback --rd-table-bar-h (research-dashboard.vue's .rd-table-bar budget calc) —
|
||||
// the table's header bar (count + Export CSV) sits ABOVE its scrollport and is not itself
|
||||
// resizable, so the map/table split must always leave it at least this much room, or the divider
|
||||
// could drag the table's usable grid to zero while its outer cell still looks non-empty.
|
||||
const TABLE_BAR_H = 32
|
||||
const TABLE_MIN_H = 120 // grid's own floor below the bar — a table smaller than this is unusable
|
||||
|
||||
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v))
|
||||
const pctNum = (v, fallback) => {
|
||||
const n = parseFloat(String(v))
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export function useDashboardLayout(options = {}) {
|
||||
const {
|
||||
shellRef,
|
||||
bus = null,
|
||||
isNarrow = false,
|
||||
pageScroll = false,
|
||||
// ★ KEY VERSIONED v2 (2026-09-23). The Driver reported "sidebars are not equally spaced" on
|
||||
// his machine AFTER the symmetric-rail change landed -- because his browser restores a layout
|
||||
// SAVED under the old defaults (rail 264), and a restored value outranks any new default by
|
||||
// design. When defaults change materially, the old saved layouts are prescriptions from a
|
||||
// different bottle: bump the key so every reader starts from the new defaults once, and a
|
||||
// fresh drag re-personalises from there. The v1 blob is left in place, inert.
|
||||
// v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted
|
||||
// shellH:502 from the previous default would silently mask this change on the Driver's browser.
|
||||
storageKey = 'rd-dashboard-layout-v6',
|
||||
} = options
|
||||
|
||||
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }
|
||||
const clamps = { ...CLAMPS, ...(options.clamps || {}) }
|
||||
|
||||
const facetW = ref(defaults.facetW) // px
|
||||
const railW = ref(defaults.railW) // px
|
||||
const tableH = ref(defaults.tableH) // string, e.g. '34%'
|
||||
const mapH = ref(defaults.mapH) // px — only meaningful/applied in page-scroll mode
|
||||
const shellH = ref(defaults.shellH) // px — page-scroll ELEMENT total height (map+table budget)
|
||||
const customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults?
|
||||
const mapHCustomized = ref(false) // page-scroll's own inline var is written independently of
|
||||
// customized (below), since an untouched grid-mode drag
|
||||
// must never suddenly pin --rd-map-h and vice versa.
|
||||
const shellHCustomized = ref(false) // same independence, one level up: the shell handle is a
|
||||
// separate drag from the map/table split and must not fire
|
||||
// just because the OTHER page-scroll var was touched.
|
||||
|
||||
const narrow = () => !!unref(isNarrow)
|
||||
const inPageScroll = () => !!unref(pageScroll)
|
||||
|
||||
// ── what --rd-shell-h CURRENTLY RESOLVES TO (BUG-SHELL-HANDLE-SNAPS-144PX) ───────────────────
|
||||
// The shell drag must anchor on the budget the CSS is resolving RIGHT NOW, or the first frame
|
||||
// "corrects" from a wrong anchor and the user sees height they did not drag. Two sources, in
|
||||
// priority order, because the variable and the rendered size legitimately disagree:
|
||||
//
|
||||
// 1. THE COMPUTED VARIABLE, whenever it is set. --rd-shell-h is a CEILING — it feeds the
|
||||
// table grid's max-height — and a ceiling is not a floor: if the table has fewer rows than
|
||||
// the budget allows, the element renders SMALLER than the variable says. MEASURED with a
|
||||
// restored shellH of 983: the element was 1126 while map+table summed to only 743, because
|
||||
// the grid had ~240px of budget and no rows to fill it. Anchoring on that 743 snapped the
|
||||
// variable 983 -> 744 on the first frame — a -239px jump, the mirror image of the +144 one
|
||||
// this card started from. Whatever the CSS already holds is what the drag must continue
|
||||
// from, so the pointer only ever ADDS to a value that is already in effect.
|
||||
// 2. THE MEASURED map + table, only when the variable is unset. Before the first drag the CSS
|
||||
// is on its stylesheet fallback (`80svh`), which shellH.value cannot stand in for: the 720
|
||||
// default only equals 80svh at a 900px viewport, so trusting it would jump by the
|
||||
// difference at any other window height.
|
||||
//
|
||||
// Measuring map + table rather than the shell's own box is deliberate — the shell is
|
||||
// `height:auto` here and includes the chrome the variable does not budget for. Summing the two
|
||||
// regions lands on the same span calc(shell-h - map-h - bar - foot) works from, without
|
||||
// restating the chrome's height as a second number that could drift from the stylesheet.
|
||||
function currentShellBudget() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return shellH.value
|
||||
const declared = parseFloat(getComputedStyle(el).getPropertyValue('--rd-shell-h'))
|
||||
if (Number.isFinite(declared) && declared > 0) return Math.round(declared)
|
||||
const mapEl = el.querySelector('[data-region="map"]')
|
||||
const tableEl = el.querySelector('[data-region="table"]')
|
||||
if (!mapEl || !tableEl) return shellH.value
|
||||
const h = mapEl.getBoundingClientRect().height + tableEl.getBoundingClientRect().height
|
||||
// A collapsed/hidden region measures 0; fall back rather than anchoring the drag on nothing.
|
||||
return h > 0 ? Math.round(h) : shellH.value
|
||||
}
|
||||
|
||||
// ── write / clear the inline CSS custom properties on the shell root ──
|
||||
// --rd-map-h is applied independently of `customized` (grid-mode's facet/rail/table-h gate):
|
||||
// page-scroll is a different layout entirely, with its own untouched-vs-dragged state, so
|
||||
// gating it on the grid vars' flag would either apply it before any page-scroll drag ever
|
||||
// happened, or (worse) suppress a real page-scroll drag because the grid vars were untouched.
|
||||
function applyVars() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
if (customized.value) {
|
||||
el.style.setProperty('--facet-w', facetW.value + 'px')
|
||||
el.style.setProperty('--rail-w', railW.value + 'px')
|
||||
el.style.setProperty('--table-h', tableH.value)
|
||||
}
|
||||
if (mapHCustomized.value) {
|
||||
el.style.setProperty('--rd-map-h', mapH.value + 'px')
|
||||
}
|
||||
if (shellHCustomized.value) {
|
||||
el.style.setProperty('--rd-shell-h', shellH.value + 'px')
|
||||
}
|
||||
}
|
||||
function clearVars() {
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
el.style.removeProperty('--facet-w')
|
||||
el.style.removeProperty('--rail-w')
|
||||
el.style.removeProperty('--table-h')
|
||||
el.style.removeProperty('--rd-map-h')
|
||||
el.style.removeProperty('--rd-shell-h')
|
||||
}
|
||||
|
||||
// ── persistence ──
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
facetW: facetW.value, railW: railW.value, tableH: tableH.value, mapH: mapH.value, shellH: shellH.value,
|
||||
}))
|
||||
} catch (_) { /* storage blocked / SSR — layout still works this session */ }
|
||||
}
|
||||
function restore() {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const s = JSON.parse(raw)
|
||||
if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW)
|
||||
if (Number.isFinite(s.railW)) railW.value = clamp(s.railW, clamps.railW)
|
||||
if (s.tableH != null) tableH.value = clamp(pctNum(s.tableH, pctNum(defaults.tableH, 34)), clamps.tableH) + '%'
|
||||
customized.value = true
|
||||
// shellH restores BEFORE mapH so the mapH clamp below (which bounds map to <= shellH, see
|
||||
// startResize's isTablePageScroll branch) has the right ceiling on first paint too.
|
||||
if (Number.isFinite(s.shellH)) { shellH.value = clamp(s.shellH, clamps.shellH); shellHCustomized.value = true }
|
||||
if (Number.isFinite(s.mapH)) { mapH.value = clamp(s.mapH, clamps.mapH); mapHCustomized.value = true }
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
// ── reset: clear the key + snap back to the EXACT defaults (hand control to the stylesheet) ──
|
||||
function reset() {
|
||||
try { localStorage.removeItem(storageKey) } catch (_) { /* ignore */ }
|
||||
facetW.value = defaults.facetW
|
||||
railW.value = defaults.railW
|
||||
tableH.value = defaults.tableH
|
||||
mapH.value = defaults.mapH
|
||||
shellH.value = defaults.shellH
|
||||
customized.value = false
|
||||
mapHCustomized.value = false
|
||||
shellHCustomized.value = false
|
||||
clearVars()
|
||||
}
|
||||
|
||||
// ── drag-live signalling (BUG-RESIZE-STROBE) ──────────────────────────────
|
||||
// Two channels, because two different consumers need it:
|
||||
// 1. `data-rd-resizing` on the shell root — a pure-CSS hook so transitions/animations can be
|
||||
// killed for the duration of the drag (no JS needed, works inside shadow DOM).
|
||||
// 2. bus 'layoutResizing' — lets the map panel downgrade to a cheap canvas resize while the
|
||||
// drag is live, then do the one expensive re-fit on drag end.
|
||||
// Both are no-ops when absent, so this composable stays usable without a bus.
|
||||
const resizing = ref(false)
|
||||
function beginDrag() {
|
||||
if (resizing.value) return
|
||||
resizing.value = true
|
||||
const el = shellRef && shellRef.value
|
||||
if (el) el.setAttribute('data-rd-resizing', '1')
|
||||
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', true) } catch (_) {} }
|
||||
}
|
||||
function endDrag() {
|
||||
if (!resizing.value) return
|
||||
resizing.value = false
|
||||
const el = shellRef && shellRef.value
|
||||
if (el) el.removeAttribute('data-rd-resizing')
|
||||
if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', false) } catch (_) {} }
|
||||
}
|
||||
|
||||
// ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ──
|
||||
function startResize(axis, e) {
|
||||
if (narrow()) return
|
||||
const el = shellRef && shellRef.value
|
||||
if (!el) return
|
||||
e.preventDefault()
|
||||
const rect = el.getBoundingClientRect()
|
||||
const startX = e.clientX
|
||||
const startFacet = facetW.value
|
||||
const startRail = railW.value
|
||||
const handleEl = e.currentTarget
|
||||
// ── PAGE-SCROLL AXIS SWITCH (BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING) ──────────────────────
|
||||
// In page-scroll mode (research-dashboard.vue's pageScroll / dashboard-plus-grid.vue) the
|
||||
// shell is `height:auto` and grows with its content, so `rect` above is NOT one screen tall
|
||||
// — `rect.bottom` can sit thousands of px below the viewport. --table-h is force-set to
|
||||
// `auto` by the .rd-page-scroll stylesheet rule and no longer drives ANY layout: the map's
|
||||
// height instead comes from --rd-map-h (default 40svh as of BUG-DASHBOARD-BAND-GROWS-ENORMOUS;
|
||||
// see the CSS note beside it), and the table's own max-height comes from --rd-table-max-h.
|
||||
// Writing tableH in this mode is a complete no-op — MEASURED (at the prior 58svh default):
|
||||
// map height 522px before and after a real 200px drag, every
|
||||
// frame. The fix is to drive --rd-map-h directly, from the MAP ELEMENT's own top edge
|
||||
// (not the unbounded shell rect), so growing the map is a plain function of where the
|
||||
// pointer is relative to where the map starts.
|
||||
//
|
||||
// ★ THE map/table SPLIT vs. THE shell's OWN HEIGHT (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER)
|
||||
// — this handle must move height BETWEEN map and table, never grow the total. mapH is now
|
||||
// clamped to leave the table at least TABLE_BAR_H + TABLE_MIN_H of the FIXED shellH budget,
|
||||
// so dragging this divider can only redistribute the two, exactly like resizing an internal
|
||||
// pane split rather than the window itself.
|
||||
const isTablePageScroll = axis === 'table' && inPageScroll()
|
||||
const isShellResize = axis === 'shell' && inPageScroll()
|
||||
let startMapTop = 0
|
||||
let mapHCeiling = clamps.mapH[1]
|
||||
if (isTablePageScroll) {
|
||||
const mapEl = el.querySelector('[data-region="map"]')
|
||||
const mapRect = mapEl ? mapEl.getBoundingClientRect() : null
|
||||
startMapTop = mapRect ? mapRect.top : (e.clientY - mapH.value)
|
||||
mapHCeiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H))
|
||||
}
|
||||
// ★ DELTA-ANCHORED ON THE LIVE BUDGET (BUG-SHELL-HANDLE-SNAPS-144PX-ON-FIRST-FRAME).
|
||||
// MEASURED at 1440x900: a 1px drag grew the element 144px. TWO defects stacked here, and
|
||||
// fixing only the first leaves the number bit-for-bit unchanged — which is the clue that
|
||||
// named the second.
|
||||
//
|
||||
// 1. POINTER-ABSOLUTE math. The old line set shellH to (pointerY - shellTop), treating the
|
||||
// cursor's Y AS the new height. That is only correct if the handle sits exactly shellH
|
||||
// px below the shell's top, and it does not. Fixed by anchoring on a start value and
|
||||
// adding the pointer DELTA.
|
||||
// 2. THE WRONG ANCHOR — the actual source of the 144. Anchoring on the element's measured
|
||||
// height is the obvious move and is wrong, because --rd-shell-h IS NOT THE ELEMENT'S
|
||||
// HEIGHT. The shell is `height:auto` in page-scroll mode (.rd-shell.rd-page-scroll) and
|
||||
// nothing reads --rd-shell-h as a height; its ONLY consumer is the table grid's
|
||||
// max-height: calc(--rd-shell-h - --rd-map-h - bar - foot). So the variable budgets
|
||||
// MAP + TABLE and excludes the chrome above them (topbar 52 + filters 55 + stats 30 =
|
||||
// 141px measured). Seeding it with the measured 863 published a budget 144px too large:
|
||||
// the grid's max-height jumped 300 -> 444 on the FIRST frame and the element grew with
|
||||
// it. 863 - 719 = 144 is not a coincidence — it is the chrome, double-counted.
|
||||
const startShellY = e.clientY
|
||||
const startShellH = isShellResize ? currentShellBudget() : shellH.value
|
||||
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
|
||||
|
||||
// ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ──
|
||||
// Writing the CSS vars every pointermove reflows the grid, which fires the map panel's
|
||||
// ResizeObserver → map.resize() → MapLibre's own 'resize' event → useMapInstance's
|
||||
// _onResize → setMinZoom/setZoom. Because the zoom FLOOR is derived from the container
|
||||
// HEIGHT, a vertical drag re-snapped the camera on EVERY frame — a ~60Hz full-canvas
|
||||
// re-render that reads as strobing. Flagging the drag lets the map coalesce to a single
|
||||
// cheap canvas resize while dragging and do the one expensive re-fit on drag END.
|
||||
beginDrag()
|
||||
|
||||
// Coalesce the inline-var writes to one per animation frame. pointermove can fire faster
|
||||
// than the compositor on high-polling-rate mice, so without this the grid could reflow
|
||||
// several times per painted frame — wasted work that makes the strobe worse.
|
||||
let frame = 0
|
||||
let pending = null
|
||||
function flush() {
|
||||
frame = 0
|
||||
if (!pending) return
|
||||
const ev = pending
|
||||
pending = null
|
||||
if (axis === 'facet') {
|
||||
// left facet edge → dragging right widens the facet column
|
||||
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
|
||||
customized.value = true
|
||||
} else if (axis === 'rail') {
|
||||
// right rail's LEFT edge → dragging left widens the rail column
|
||||
railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW))
|
||||
customized.value = true
|
||||
} else if (isTablePageScroll) {
|
||||
// page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is
|
||||
// align-self:end on the map grid-area — see the CSS below), whose top never moves. The
|
||||
// pointer's new Y position IS the map's new bottom edge, so the new height is simply the
|
||||
// distance from the map's (fixed) top to the cursor — a direct 1:1 follow, exactly like
|
||||
// dragging the corner of a window. Re-derived fresh every frame from startMapTop (not
|
||||
// summed from a delta), so it can never drift from the cursor across a long drag.
|
||||
// Clamped to mapHCeiling (not the raw clamps.mapH max) — the ceiling reserves the table's
|
||||
// bar + a usable minimum out of the FIXED shellH budget, so this drag only ever moves
|
||||
// height between map and table and can never grow the element's total height. That is
|
||||
// the shell axis's job (see isShellResize below), not this one's.
|
||||
mapH.value = Math.round(clamp(ev.clientY - startMapTop, [clamps.mapH[0], mapHCeiling]))
|
||||
mapHCustomized.value = true
|
||||
} else if (axis === 'table') {
|
||||
// map/table divider → dragging up grows the table (height measured from shell bottom)
|
||||
const pct = ((rect.bottom - ev.clientY) / rect.height) * 100
|
||||
tableH.value = Math.round(clamp(pct, clamps.tableH)) + '%'
|
||||
customized.value = true
|
||||
} else if (isShellResize) {
|
||||
// The NEW shell handle — spans the whole element's bottom edge (research-dashboard.vue's
|
||||
// .rd-rz-shell), the boundary with whatever a host stacks below (e.g. the people-groups
|
||||
// grid in dashboard-plus-grid.vue). Same fixed-top / 1:1-follow pattern as the map handle
|
||||
// above. This is the ONLY axis that changes --rd-shell-h, i.e. the only handle that
|
||||
// changes the element's total height — the map/table divider above must never do this
|
||||
// (see its own comment).
|
||||
// DELTA from the live start budget, NOT the pointer's absolute Y: unlike the map handle,
|
||||
// this variable is not a distance from a fixed top edge, so there is no "pointer Y IS the
|
||||
// new value" identity to exploit — assuming one produced the 144px snap (see the note on
|
||||
// startShellH above).
|
||||
shellH.value = Math.round(clamp(startShellH + (ev.clientY - startShellY), clamps.shellH))
|
||||
shellHCustomized.value = true
|
||||
}
|
||||
applyVars()
|
||||
}
|
||||
function move(ev) {
|
||||
// keep only the LATEST position; the rAF does the single write for this frame
|
||||
pending = { clientX: ev.clientX, clientY: ev.clientY }
|
||||
if (!frame) frame = requestAnimationFrame(flush)
|
||||
}
|
||||
function up(ev) {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
if (frame) { cancelAnimationFrame(frame); frame = 0 }
|
||||
flush() // land the final position even if the last frame never ran
|
||||
try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {}
|
||||
persist()
|
||||
endDrag()
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
}
|
||||
|
||||
const startFacetResize = (e) => startResize('facet', e)
|
||||
const startRailResize = (e) => startResize('rail', e)
|
||||
const startTableResize = (e) => startResize('table', e)
|
||||
const startShellResize = (e) => startResize('shell', e)
|
||||
|
||||
// ── keyboard step-resize (a11y: role="separator" needs an operable equivalent to the drag) ──
|
||||
// Reuses the SAME refs/clamps/persist/applyVars as the pointer path — no parallel math. One
|
||||
// discrete step per keypress (no rAF coalescing needed; a keydown can't fire faster than a frame).
|
||||
// `table` axis mirrors startResize's own page-scroll branch: in page-scroll mode it steps mapH
|
||||
// (the map/table split within the FIXED shellH budget), otherwise it steps the grid-mode tableH
|
||||
// percentage. `shell` steps shellH directly — the only axis that changes the element's total height.
|
||||
const STEP = { facet: 16, rail: 16, table: 24, mapH: 24, shellH: 24 } // px per arrow-key press (tableH in %)
|
||||
// `toExtreme` bypasses the per-step delta entirely and clamps straight to lo/hi — Home/End jump,
|
||||
// not a long walk of individual steps (which would also mean 200 localStorage writes per keypress).
|
||||
function stepResize(axis, dir, toExtreme) {
|
||||
if (narrow()) return
|
||||
if (axis === 'facet') {
|
||||
facetW.value = toExtreme ? (dir < 0 ? clamps.facetW[0] : clamps.facetW[1])
|
||||
: Math.round(clamp(facetW.value + dir * STEP.facet, clamps.facetW))
|
||||
customized.value = true
|
||||
} else if (axis === 'rail') {
|
||||
// Same "left edge, dragging left widens" sense as the pointer path: ArrowLeft (dir -1)
|
||||
// widens the rail, so the rail's step direction is inverted relative to facet's.
|
||||
railW.value = toExtreme ? (dir < 0 ? clamps.railW[1] : clamps.railW[0])
|
||||
: Math.round(clamp(railW.value - dir * STEP.rail, clamps.railW))
|
||||
customized.value = true
|
||||
} else if (axis === 'table' && inPageScroll()) {
|
||||
// Same ceiling as the pointer path (startResize's mapHCeiling): reserve the table's bar +
|
||||
// usable minimum out of the CURRENT shellH so a keyboard drag can redistribute but never
|
||||
// grow the total either.
|
||||
const ceiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H))
|
||||
mapH.value = toExtreme ? (dir < 0 ? clamps.mapH[0] : ceiling)
|
||||
: Math.round(clamp(mapH.value + dir * STEP.mapH, [clamps.mapH[0], ceiling]))
|
||||
mapHCustomized.value = true
|
||||
} else if (axis === 'table') {
|
||||
const pct = toExtreme ? (dir < 0 ? clamps.tableH[0] : clamps.tableH[1])
|
||||
: clamp(pctNum(tableH.value, pctNum(defaults.tableH, 34)) - dir * STEP.table / 4, clamps.tableH)
|
||||
tableH.value = Math.round(pct) + '%'
|
||||
customized.value = true
|
||||
} else if (axis === 'shell' && inPageScroll()) {
|
||||
shellH.value = toExtreme ? (dir < 0 ? clamps.shellH[0] : clamps.shellH[1])
|
||||
: Math.round(clamp(shellH.value + dir * STEP.shellH, clamps.shellH))
|
||||
shellHCustomized.value = true
|
||||
}
|
||||
applyVars()
|
||||
persist()
|
||||
}
|
||||
// ArrowUp/Left = grow toward the start edge, ArrowDown/Right = grow toward the end edge, matching
|
||||
// each handle's drag SENSE (facet/rail are col-resize, table/map/shell are row-resize). Home/End
|
||||
// jump to the axis's clamp extremes in one write (not a walk of individual steps).
|
||||
function onResizeKeydown(axis, e) {
|
||||
const K = e.key
|
||||
let dir = 0
|
||||
if (axis === 'table' || axis === 'shell') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 }
|
||||
else { if (K === 'ArrowLeft') dir = -1; else if (K === 'ArrowRight') dir = 1 }
|
||||
if (dir) { e.preventDefault(); stepResize(axis, dir); return }
|
||||
if (K === 'Home' || K === 'End') {
|
||||
e.preventDefault()
|
||||
stepResize(axis, K === 'Home' ? -1 : 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ── lifecycle: restore + apply after the shell el exists; wire the reset bus event ──
|
||||
let offReset = null
|
||||
onMounted(() => {
|
||||
if (restore()) applyVars()
|
||||
if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
try { offReset && offReset() } catch (_) {}
|
||||
// If the component tears down mid-drag, release the suppression so a re-mounted map
|
||||
// is never left permanently in the cheap "resizing" mode.
|
||||
try { endDrag() } catch (_) {}
|
||||
})
|
||||
|
||||
return {
|
||||
facetW, railW, tableH, mapH, shellH, customized, resizing,
|
||||
startFacetResize, startRailResize, startTableResize, startShellResize,
|
||||
startResize, reset, applyVars, stepResize, onResizeKeydown,
|
||||
}
|
||||
}
|
||||
export { useDashboardLayout } from '@map/composables/useDashboardLayout.js'
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
*/
|
||||
import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
|
||||
import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { createSelectionBus } from './useSelectionBus.js'
|
||||
import { createSelectionBus } from '@map/composables/useSelectionBus.js'
|
||||
import { useDashboardData } from './useDashboardData.js'
|
||||
import { useCsvExport } from './useCsvExport.js'
|
||||
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
|
||||
|
|
@ -227,13 +227,21 @@ function useResponsiveShell(bus) {
|
|||
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
||||
// Minimal escaping, maximal legibility: reserve our three delimiters + %, keep spaces as `+`
|
||||
// (the x-www-form-urlencoded convention), leave everything else -- including unicode -- alone.
|
||||
const escapeValue = (s) => String(s).replace(/[%;:*+]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
|
||||
// `,` joins the values of ONE key (D1: OR-within-key). It is reserved here for the same reason
|
||||
// `;` `:` and `*` already are — a delimiter that can also occur inside a value is not a
|
||||
// delimiter — so a literal comma in a facet value is percent-encoded before the join.
|
||||
const escapeValue = (s) => String(s).replace(/[%;:*+,]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
|
||||
const unescapeValue = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, '%20')) } catch (_) { return String(s).replace(/\+/g, ' ') } }
|
||||
|
||||
function useHashSync(bus, labelFor) {
|
||||
function syncHash() {
|
||||
try {
|
||||
const pairs = Object.entries(bus.filterSet).map(([k, v]) => {
|
||||
// A MULTI-VALUE KEY KEEPS THE CODE CANONICAL AND DROPS THE SLUG. The `*slug` is
|
||||
// display-only and singular by construction — one human name for one code — so there is no
|
||||
// honest slug for "these twenty-two codes". Writing one would put a label in a URL that
|
||||
// does not describe what the URL selects, and the slug is already stripped on parse.
|
||||
if (Array.isArray(v)) return k + ':' + v.map(escapeValue).join(',')
|
||||
const label = typeof labelFor === 'function' ? labelFor(k, v) : null
|
||||
const slug = label && String(label) !== String(v) ? slugify(label) : ''
|
||||
return k + ':' + escapeValue(v) + (slug ? '*' + slug : '')
|
||||
|
|
@ -257,8 +265,12 @@ function useHashSync(bus, labelFor) {
|
|||
const i = pair.indexOf(':')
|
||||
if (i < 1) continue
|
||||
const key = pair.slice(0, i)
|
||||
const value = unescapeValue(pair.slice(i + 1).split('*')[0])
|
||||
if (key && value) bus.setFilter(key, value)
|
||||
const raw = pair.slice(i + 1).split('*')[0]
|
||||
// Split BEFORE unescaping: a literal comma inside a value is percent-encoded by
|
||||
// escapeValue, so every `,` still present here is a separator. Unescaping first would
|
||||
// resurrect the content commas and split the value in half.
|
||||
const parts = raw.split(',').map(unescapeValue).filter(Boolean)
|
||||
if (key && parts.length) bus.setFilter(key, parts.length > 1 ? parts : parts[0])
|
||||
}
|
||||
} catch (_) { /* ignore bad hash */ }
|
||||
})
|
||||
|
|
@ -293,7 +305,9 @@ export function useDashboardShell(config) {
|
|||
const hasText = !!(bus.searchTerm?.value || '').trim()
|
||||
if (!keys.length && !hasText) return rows
|
||||
const faceted = keys.length
|
||||
? rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true }))
|
||||
// ONE predicate, owned by the bus (SPEC-DASHBOARD-KIT D1). This line was the seventh copy
|
||||
// of `a(r) === filterSet[k]`; with multi-value filters the copies would have disagreed.
|
||||
? rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC))
|
||||
: rows
|
||||
// Tiered search over the faceted scope: exact first, fuzzy only if exact finds nothing.
|
||||
return filterRowsBySearch(faceted, matchSearch)
|
||||
|
|
@ -355,11 +369,12 @@ export function useDashboardShell(config) {
|
|||
// here, below `dataStore`, because the resolver reads the normalized features it exposes.
|
||||
// Data columns are unaffected (see useCsvExport.buildCsv).
|
||||
const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId)
|
||||
// The exporter selects rows with the BUS's predicate, not a copy of it (D1).
|
||||
|
||||
// shareable view-state hash URL -- below labelFor on purpose: the hash's display-only slugs
|
||||
// resolve through the SAME code->name resolver the chips and the CSV provenance line use.
|
||||
useHashSync(bus, labelFor)
|
||||
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor })
|
||||
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor, rowMatches: bus.rowMatchesFilters })
|
||||
|
||||
provide('selectionBus', bus)
|
||||
provide('dashboardData', data)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,13 @@ export function useTreeLegendLabels(cfg, dataStore, searchSourceId) {
|
|||
*/
|
||||
function labelFor(facetKey, value) {
|
||||
const map = affinityLabelMaps.value[facetKey]
|
||||
// ★ ARRAY-AWARE AT THE ONE OWNER (SPEC-DASHBOARD-KIT D1). Every surface that turns a filter
|
||||
// into words goes through here — the chip bar, the research-filter chips and the CSV
|
||||
// provenance comment — so resolving the list once is what keeps the three saying the same
|
||||
// thing. Without it an array fell through to its default toString and a chip read
|
||||
// "Religion: Islam,Hinduism": the codes joined by a bare comma, with no spacing and no code
|
||||
// resolved to its human name.
|
||||
if (Array.isArray(value)) return value.map((v) => (map && map.get(v)) || v).join(', ')
|
||||
if (!map) return value
|
||||
return map.get(value) || value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ watch(textDir, reflectDir)
|
|||
// The absolute url this composite is shared and iframed as. Built here rather than written as a
|
||||
// literal because resolveEmbedBase() walks profileConfig.embedBaseUrl → window.MAP_EMBED_BASE_URL →
|
||||
// the root this bundle derives from its OWN script url, so the link is right on doxa.life, on
|
||||
// nuxtbase.app and in a dropped tree with nothing configured.
|
||||
// the live test host and in a dropped tree with nothing configured.
|
||||
//
|
||||
// It MUST be absolute, which is the non-obvious part. resolveShareUrl returns a tier-1 shareUrl
|
||||
// VERBATIM (embedBase.js:95 — `return whole.trim()`, no resolution), and ShareButton feeds the same
|
||||
|
|
@ -370,7 +370,16 @@ function onMapFiltered(ev) {
|
|||
// then takes the whole set and this becomes a real narrowing instead of an honest abstention.
|
||||
function gridLanguage() {
|
||||
const codes = Array.isArray(linked.language.codes) ? linked.language.codes : []
|
||||
if (codes.length > 1) return ''
|
||||
// ★ THE ABSTENTION IS OVER (SPEC-DASHBOARD-KIT D1). When the map reported a language TIER —
|
||||
// "Arabic", 23 ISO codes — there was no single value that meant it, so this left the axis
|
||||
// unfiltered: honest, and visibly incomplete, because the map narrowed and the grid did not.
|
||||
// The grid's valueMatches() now takes a LIST and matches OR-within-the-axis, so the whole
|
||||
// selection crosses as the codes it actually is. Still codes and never the tier NAME: the name
|
||||
// matches no row that reads "Arabic, Sudanese", which is what emptied the grid in the first
|
||||
// place.
|
||||
// `|`, not `,`: a language LABEL routinely contains a comma ("Arabic, Mesopotamian"), so a
|
||||
// comma-joined list is ambiguous with a single value. See valueMatches() in people-groups-grid.
|
||||
if (codes.length > 1) return codes.join('|')
|
||||
return linked.language.value || linked.language.label
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-filter-multivalue.mjs — OR-within-key, AND-across-keys, and ONE predicate that says so.
|
||||
*
|
||||
* SPEC-DASHBOARD-KIT D1. `filterSet[key]` may hold an array meaning "any of these"; keys still
|
||||
* AND together. This gate exists because the change is four lines of behaviour sitting on top of a
|
||||
* rule that was WRITTEN OUT SEVEN TIMES.
|
||||
*
|
||||
* THE DANGER D1 CREATED, which is the whole reason this is gated rather than reviewed:
|
||||
* `a(r) === filterSet[k]` appeared in the shell's filteredRows, the facet rail twice, the map
|
||||
* panel, the table, the charts rail and the CSV exporter. Seven copies of a one-character rule are
|
||||
* survivable. The moment the rule grows an array case, six of the seven keep comparing an array
|
||||
* with `===`, every one of them quietly matches NOTHING, and the panels disagree with each other
|
||||
* about what is on screen — the facet rail printing counts for a scope the map and table no longer
|
||||
* render, which is a number a reader acts on before clicking. So D1 is not only "arrays are
|
||||
* allowed"; it is "there is one function that answers *does this row match*, and every panel calls
|
||||
* it". This gate asserts the second half, because only the second half can be silently undone.
|
||||
*
|
||||
* WHAT IT CHECKS:
|
||||
* A. BEHAVIOUR, by running the real bus: an array ORs within the key; keys AND; a one-element
|
||||
* array stores as the scalar it means; an EMPTY array clears rather than matching nothing;
|
||||
* toggleFilterValue adds, removes, and clears on the last value; the cascade still fires.
|
||||
* B. NO SURVIVING COPIES: `=== filterSet[k]` / `=== bus.filterSet[k]` must not appear in any
|
||||
* panel. One allowed exception, named: useCsvExport's parameter default, which exists so a
|
||||
* caller predating D1 does not throw.
|
||||
* C. THE HASH round-trips a list, including a value that CONTAINS a comma — a delimiter that can
|
||||
* also occur inside a value is not a delimiter.
|
||||
* D. THE GRID'S END matches the same way: valueMatches() takes a list and ORs it, so the two
|
||||
* implementations of one rule cannot drift into disagreeing.
|
||||
*
|
||||
* NOT CHECKED, because it is deliberately NOT part of D1: a plain click still REPLACES rather than
|
||||
* adds. D1 gives the bus the capability; making a rail row additive on an unmodified click would
|
||||
* change what every existing click does to the flagship, which is a product decision and not this
|
||||
* one. The array path is reached programmatically today (the composite's language code set).
|
||||
*
|
||||
* NEGATIVE-TESTED: restoring `=== filterSet[k]` in any panel fails B naming the file; making
|
||||
* facetMatches use `includes` on a scalar fails A; dropping the comma from escapeValue's reserved
|
||||
* set fails C. No browser.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TAG = 'FILTER-MULTIVALUE'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const fails = []
|
||||
const notes = []
|
||||
const eq = (what, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||
|
||||
// ── A. the real bus, exercised ───────────────────────────────────────────────────────────────
|
||||
const BUS = join(ROOT, 'library/composables/useSelectionBus.js')
|
||||
if (!existsSync(BUS)) { console.log(`${TAG}: SKIP — no ${BUS.replace(ROOT + '/', '')} (kit landing 1 not applied).`); process.exit(0) }
|
||||
const { createSelectionBus } = await import(BUS)
|
||||
{
|
||||
const bus = createSelectionBus()
|
||||
bus.configureHierarchy({ facetRail: [{ id: 'place', drill: ['region', 'block', 'country'] }] })
|
||||
bus.setFilter('religion', ['Islam', 'Hinduism'])
|
||||
eq('A array is stored as an array', bus.filterSet.religion, ['Islam', 'Hinduism'])
|
||||
eq('A OR within the key (member)', bus.facetMatches('Hinduism', bus.filterSet.religion), true)
|
||||
eq('A OR within the key (non-member)', bus.facetMatches('Jewish', bus.filterSet.religion), false)
|
||||
eq('A isFilterActive sees into the array', bus.isFilterActive('religion', 'Islam'), true)
|
||||
|
||||
bus.setFilter('region', 'Asia')
|
||||
const ACC = { religion: (r) => r.religion, region: (r) => r.region }
|
||||
eq('A AND across keys (both hold)', bus.rowMatchesFilters({ religion: 'Islam', region: 'Asia' }, ACC), true)
|
||||
eq('A AND across keys (one fails)', bus.rowMatchesFilters({ religion: 'Islam', region: 'Africa' }, ACC), false)
|
||||
eq('A a key with no accessor is skipped, not failed', bus.rowMatchesFilters({ religion: 'Islam', region: 'Asia' }, { religion: ACC.religion }), true)
|
||||
|
||||
bus.setFilter('religion', ['Islam'])
|
||||
eq('A a one-element array stores as the scalar it means', bus.filterSet.religion, 'Islam')
|
||||
bus.setFilter('religion', [])
|
||||
eq('A an EMPTY array CLEARS rather than matching nothing', 'religion' in bus.filterSet, false)
|
||||
|
||||
bus.setFilter('tags', 'a'); bus.toggleFilterValue('tags', 'b')
|
||||
eq('A toggle adds', bus.filterSet.tags, ['a', 'b'])
|
||||
bus.toggleFilterValue('tags', 'b')
|
||||
eq('A toggle removes, collapsing to the scalar', bus.filterSet.tags, 'a')
|
||||
bus.toggleFilterValue('tags', 'a')
|
||||
eq('A toggling the last value clears the key', 'tags' in bus.filterSet, false)
|
||||
|
||||
// The hierarchy contract must still hold with arrays in play.
|
||||
bus.setFilter('region', ['Asia', 'Africa']); bus.setFilter('block', 'South Asia'); bus.setFilter('country', 'IND')
|
||||
bus.setFilter('region', null)
|
||||
eq('A clearing a multi-value parent still cascades', Object.keys(bus.filterSet).sort(), [])
|
||||
notes.push('bus: OR within a key, AND across keys, empty array clears, cascade intact')
|
||||
}
|
||||
|
||||
// ── B. no surviving copies of the predicate ──────────────────────────────────────────────────
|
||||
{
|
||||
const PANELS = [
|
||||
'plugin-designs/research-dashboard/src/composables/useDashboardShell.js',
|
||||
'plugin-designs/research-dashboard/src/composables/useCsvExport.js',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue',
|
||||
]
|
||||
for (const rel of PANELS) {
|
||||
const p = join(ROOT, rel)
|
||||
if (!existsSync(p)) { fails.push(`B ${rel} is missing — this gate is blind to it.`); continue }
|
||||
for (const [i, line] of readFileSync(p, 'utf8').split('\n').entries()) {
|
||||
if (/^\s*(\/\/|\*)/.test(line)) continue // a comment quoting the old rule is fine
|
||||
if (!/===\s*(bus\.)?filterSet\[/.test(line)) continue
|
||||
// THE ONE ALLOWED SITE, named rather than pattern-matched: the exporter's parameter default,
|
||||
// which keeps a pre-D1 caller working instead of throwing.
|
||||
if (rel.endsWith('useCsvExport.js') && /rowMatches\s*=/.test(line)) continue
|
||||
fails.push(`B ${rel}:${i + 1} still compares with === against filterSet — with an array that matches NOTHING, and this panel would disagree with the others about what is on screen.\n ${line.trim().slice(0, 140)}`)
|
||||
}
|
||||
}
|
||||
if (!fails.some((f) => f.startsWith('B'))) notes.push(`panels: ${PANELS.length} checked, no surviving copy of the predicate`)
|
||||
}
|
||||
|
||||
// ── C. the hash round-trips a list, commas and all ───────────────────────────────────────────
|
||||
{
|
||||
const src = readFileSync(join(ROOT, 'plugin-designs/research-dashboard/src/composables/useDashboardShell.js'), 'utf8')
|
||||
const esc = /const escapeValue = (\(s\) =>[^\n]*)/.exec(src)
|
||||
const unesc = /const unescapeValue = (\(s\) =>[^\n]*)/.exec(src)
|
||||
if (!esc || !unesc) fails.push('C could not lift escapeValue/unescapeValue from useDashboardShell.js — the hash format moved and this gate is blind to it.')
|
||||
else {
|
||||
const escapeValue = new Function('return ' + esc[1])()
|
||||
const unescapeValue = new Function('return ' + unesc[1])()
|
||||
for (const list of [['apd', 'acm', 'ayl'], ['Arabic, Sudanese'], ['Islam', 'Hinduism'], ['a+b', 'c;d']]) {
|
||||
const back = list.map(escapeValue).join(',').split(',').map(unescapeValue)
|
||||
eq(`C hash round-trip ${JSON.stringify(list)}`, back, list)
|
||||
}
|
||||
if (!fails.some((f) => f.startsWith('C'))) notes.push('hash: a list round-trips, including a value that contains a comma')
|
||||
}
|
||||
}
|
||||
|
||||
// ── D. the grid ORs the same way ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
const src = readFileSync(join(ROOT, 'plugin-designs/people-groups-grid/people-groups-grid.vue'), 'utf8')
|
||||
const m = /function valueMatches\(vl, selection\) \{([\s\S]*?)\n\}/.exec(src)
|
||||
const n = /const norm = ([^\n]*)/.exec(src)
|
||||
if (!m || !n) fails.push('D could not lift valueMatches/norm from people-groups-grid.vue — the grid matcher moved.')
|
||||
else {
|
||||
const valueMatches = new Function('norm', `return function valueMatches(vl, selection) {${m[1]}\n}`)(new Function('return ' + n[1])())
|
||||
const row = { value: 'acm', label: 'Arabic, Mesopotamian' }
|
||||
eq('D grid matches a member of the list', valueMatches(row, { value: 'apd|acm|ayl' }), true)
|
||||
eq('D grid rejects a non-member', valueMatches(row, { value: 'apd|ayl' }), false)
|
||||
eq('D grid still matches a single value', valueMatches(row, { value: 'acm' }), true)
|
||||
// ★ THIS ASSERTION IS THE ONE THAT EARNED ITS KEEP. The first D1 draft split the selection on
|
||||
// `,` and broke label matching outright — 40% of live language labels are comma-inverted, so
|
||||
// the selection was chopped in half and matched nothing. Asserting a behaviour you are KEEPING
|
||||
// is what caught it; asserting only the behaviour you are adding would have shipped it.
|
||||
eq('D grid still matches on the LABEL, commas and all', valueMatches(row, { value: 'Arabic, Mesopotamian' }), true)
|
||||
eq('D an empty selection matches nothing', valueMatches(row, { value: '' }), false)
|
||||
if (!fails.some((f) => f.startsWith('D'))) notes.push('grid: valueMatches ORs a list, and a single value takes the path it always did')
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of notes) console.log(' · ' + n)
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG}: FAIL`)
|
||||
for (const f of fails) console.error(' ✗ ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — one predicate, OR within a key, AND across keys, and both ends of the link agree.`)
|
||||
|
|
@ -36,97 +36,220 @@
|
|||
* naming the key and the call site; restoring it returns PASS. It FAILS on today's tree.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { resolve, dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TAG = 'I18N-KEYS-EXIST'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const EN = join(ROOT, 'library/i18n/locales/en')
|
||||
if (!existsSync(EN)) { console.log(`${TAG}: SKIP — no English catalogue at ${relative(ROOT, EN)}.`); process.exit(0) }
|
||||
const argOf = (flag) => { const i = process.argv.indexOf(flag); return i >= 0 ? process.argv[i + 1] : '' }
|
||||
// ★ --rev READS A TREE YOU ARE NOT STANDING IN, straight from the object store.
|
||||
// A fleet-wide census has to be taken against CURRENT main, not against one owner's worktree: every
|
||||
// bundle but mine is stale here, and a stale scan reports keys another owner added an hour ago. That
|
||||
// would mean mailing seven owners a list of defects they had already fixed — the cried-wolf failure,
|
||||
// arriving in somebody else's inbox. Checking out a second tree was not an option either (the box is
|
||||
// at 97% disk), so the files are read as blobs. No worktree, no temp copy, no writes.
|
||||
const REV = argOf('--rev')
|
||||
const BY_BUNDLE = process.argv.includes('--by-bundle')
|
||||
const git = (args) => execFileSync('git', args, { cwd: ROOT, maxBuffer: 1 << 28 })
|
||||
|
||||
// ── the tree under measurement: a path -> contents reader, from disk or from a revision ──────
|
||||
let listFiles, readFile, describeTree
|
||||
if (REV) {
|
||||
// One ls-tree, then ONE cat-file --batch for every blob: ~400 files in two processes rather than
|
||||
// 400. The batch stream is "<sha> blob <size>\n<bytes>\n", so it is parsed by length, never by
|
||||
// scanning for a delimiter that could occur inside a file.
|
||||
const entries = String(git(['ls-tree', '-r', REV, '--', 'plugin-designs', 'library']))
|
||||
.split('\n').filter(Boolean)
|
||||
.map((l) => { const [meta, path] = l.split('\t'); const [, type, sha] = meta.split(/\s+/); return { type, sha, path } })
|
||||
.filter((e) => e.type === 'blob')
|
||||
const wanted = entries.filter((e) => /\.(vue|js|mjs|json)$/.test(e.path))
|
||||
const batch = execFileSync('git', ['cat-file', '--batch'], { cwd: ROOT, input: wanted.map((e) => e.sha).join('\n') + '\n', maxBuffer: 1 << 28 })
|
||||
const contents = new Map()
|
||||
let off = 0
|
||||
for (const e of wanted) {
|
||||
const nl = batch.indexOf(0x0a, off)
|
||||
const size = Number(String(batch.subarray(off, nl)).split(' ')[2])
|
||||
contents.set(e.path, batch.subarray(nl + 1, nl + 1 + size).toString('utf8'))
|
||||
off = nl + 1 + size + 1
|
||||
}
|
||||
listFiles = (pred) => [...contents.keys()].filter(pred)
|
||||
readFile = (p) => contents.get(p)
|
||||
describeTree = `${REV} (${String(git(['rev-parse', '--short', REV])).trim()})`
|
||||
} else {
|
||||
const all = []
|
||||
const walkDir = (d) => {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
|
||||
const p = join(d, e.name)
|
||||
if (e.isDirectory()) walkDir(p)
|
||||
else all.push(relative(ROOT, p))
|
||||
}
|
||||
}
|
||||
for (const s of ['plugin-designs', 'library']) { const p = join(ROOT, s); if (existsSync(p) && statSync(p).isDirectory()) walkDir(p) }
|
||||
listFiles = (pred) => all.filter(pred)
|
||||
readFile = (p) => readFileSync(join(ROOT, p), 'utf8')
|
||||
describeTree = 'the working tree'
|
||||
}
|
||||
|
||||
// ── the English catalogue, as one flat key set: "<area>.<path.to.key>" ───────────────────────
|
||||
// The area is the FILE NAME, because that is how every call site spells it: tStatic('aria.foo')
|
||||
// reads aria.json. Nested objects flatten with dots, the way tStatic walks them.
|
||||
const CAT = /^library\/i18n\/locales\/en\/[^/]+\.json$/
|
||||
const catalogues = listFiles((p) => CAT.test(p))
|
||||
if (!catalogues.length) { console.log(`${TAG}: SKIP — no English catalogue in ${describeTree}.`); process.exit(0) }
|
||||
const AREAS = new Set()
|
||||
const KEYS = new Set()
|
||||
for (const f of readdirSync(EN)) {
|
||||
if (!f.endsWith('.json')) continue
|
||||
const area = f.replace(/\.json$/, '')
|
||||
for (const p of catalogues) {
|
||||
const area = p.split('/').pop().replace(/\.json$/, '')
|
||||
AREAS.add(area)
|
||||
const walk = (node, path) => {
|
||||
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
||||
for (const [k, v] of Object.entries(node)) walk(v, path ? path + '.' + k : k)
|
||||
} else if (typeof node === 'string') {
|
||||
KEYS.add(area + '.' + path)
|
||||
}
|
||||
} else if (typeof node === 'string') KEYS.add(area + '.' + path)
|
||||
}
|
||||
try { walk(JSON.parse(readFileSync(join(EN, f), 'utf8')), '') } catch (e) {
|
||||
console.error(`${TAG}: ${f} does not parse — ${e.message}`); process.exit(1)
|
||||
try { walk(JSON.parse(readFile(p)), '') } catch (e) {
|
||||
console.error(`${TAG}: ${p} does not parse — ${e.message}`); process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── every source file that could name a key ──────────────────────────────────────────────────
|
||||
const SCAN = ['plugin-designs', 'library']
|
||||
const files = []
|
||||
// `scripts/` is build and gate TOOLING, not product UI. Skipping it is not a convenience: this
|
||||
// gate's own header quotes two real call sites as examples, and scanning itself reported them as
|
||||
// missing keys — a check that fails on its own documentation is a check nobody trusts.
|
||||
const walkDir = (d) => {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.name === 'node_modules' || e.name === 'locales' || e.name === 'scripts' || e.name.startsWith('.')) continue
|
||||
const p = join(d, e.name)
|
||||
if (e.isDirectory()) walkDir(p)
|
||||
else if (/\.(vue|js|mjs)$/.test(e.name)) files.push(p)
|
||||
const files = listFiles((p) => /\.(vue|js|mjs)$/.test(p) && !p.includes('/scripts/') && !p.includes('/locales/'))
|
||||
|
||||
// ── ★ ALIASES ARE DISCOVERED PER FILE, NOT HARDCODED ─────────────────────────────────────────
|
||||
// The first version of this gate matched tStatic/$t/t and found four missing keys while sailing
|
||||
// past the ten it was written for, because the house pattern is a file-local wrapper. Adding `T` by
|
||||
// hand took it to 22 — and that fix was the same mistake one level up, because a census of the tree
|
||||
// finds FOUR wrapper names, not one: `const T` x16, `const L` x6, `const tr` x1
|
||||
// (library/layouts/VerticalTabsLayout.vue) and `const v` x1. A gate that knows about T and not tr is
|
||||
// blind to whichever bundle used the other name, and reports it CLEAN. So the names are read out of
|
||||
// each file instead of listed here, and the next author's alias is covered the day they write it.
|
||||
//
|
||||
// TWO SHAPES, because the tree has two:
|
||||
// FULL-KEY const T = (key, en) => tStatic(key) || en -> T('aria.foo')
|
||||
// PREFIXED const L = (key, en) => tStatic('dashboard.' + key) -> L('foo') => dashboard.foo
|
||||
// The prefixed form may carry a LADDER — ResearchMapSideMenu's is
|
||||
// `tStatic('controls.' + key) || tStatic('dashboard.' + key) || en` — and at runtime the first hit
|
||||
// wins, so a key RESOLVES if ANY prefix in the ladder holds it. Those were previously counted as
|
||||
// unresolved and left unchecked; they are ordinary keys with a known prefix, and declining to
|
||||
// resolve them was leaving most of this gate's coverage on the floor.
|
||||
const BASE_FULL = ['tStatic', '$t', 't']
|
||||
function aliasesIn(src) {
|
||||
const full = new Set(BASE_FULL)
|
||||
const prefixed = new Map() // name -> [prefix, …]
|
||||
// `const NAME = ... tStatic(...)` on one line — all eight declarations in the tree are one-liners.
|
||||
for (const m of src.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n]*tStatic[^\n]*)/g)) {
|
||||
const [, name, body] = m
|
||||
const prefixes = [...body.matchAll(/tStatic\(\s*'([A-Za-z_][\w]*)\.'\s*\+/g)].map((x) => x[1])
|
||||
if (prefixes.length) prefixed.set(name, prefixes)
|
||||
// A wrapper passes its FIRST PARAMETER straight through: `(key, en) => tStatic(key)`. Matching
|
||||
// on the parameter NAME rather than on the word "key" keeps it honest if someone calls it `k`.
|
||||
else {
|
||||
const param = /\(\s*([A-Za-z_$][\w$]*)/.exec(body)?.[1]
|
||||
if (param && new RegExp(`tStatic\\(\\s*${param}\\b`).test(body)) full.add(name)
|
||||
else if (/=\s*tStatic\s*$/.test(body.trim())) full.add(name)
|
||||
}
|
||||
}
|
||||
return { full, prefixed }
|
||||
}
|
||||
for (const s of SCAN) { const p = join(ROOT, s); if (existsSync(p) && statSync(p).isDirectory()) walkDir(p) }
|
||||
|
||||
// A literal key: the first argument is a single-quoted FULL dotted key.
|
||||
// ★ `T(` IS IN THE LIST, AND LEAVING IT OUT WOULD HAVE MADE THIS GATE USELESS ON THE VERY DEFECT
|
||||
// THAT PROMPTED IT. The house pattern in this repo is a file-local alias —
|
||||
// `const T = (key, en) => tStatic(key) || en` — so almost every call site in the dashboard reads
|
||||
// `T('aria.resizeFiltersRail', …)`, not `tStatic(...)`. A first draft matched only tStatic/$t/t and
|
||||
// reported four missing keys while sailing past the ten resize keys it was written for. The alias
|
||||
// takes a full key, exactly as tStatic does, so it belongs here.
|
||||
const LITERAL = /(?:^|[^\w$.])(?:tStatic|\$t|t|T)\(\s*'([a-zA-Z_][\w]*(?:\.[\w-]+)+)'/g
|
||||
// A DYNAMIC key: the first argument is a concatenation or a template literal.
|
||||
// ★ `L(` IS DELIBERATELY NOT RESOLVED. BpResearchHeader declares
|
||||
// `const L = (key, en) => tStatic('grid.' + key) || tStatic('dashboard.' + key) || en`, so its
|
||||
// first argument is a BARE key with a file-local prefix that this gate would have to guess at —
|
||||
// and guessing is how a gate starts reporting keys nobody wrote. Counted as unresolved, like any
|
||||
// other runtime expression.
|
||||
const DYNAMIC = /(?:^|[^\w$.])(?:tStatic|\$t|t|T|L)\(\s*(?:'[^']*'\s*\+|`|'[a-z_]+'\s*,)/g
|
||||
const missing = new Map() // key -> { sites: [], via: Set }
|
||||
let literalCount = 0, prefixedCount = 0, dynamicCount = 0
|
||||
const lineAt = (src, i) => src.slice(0, i).split('\n').length
|
||||
const record = (key, site, via) => {
|
||||
if (!missing.has(key)) missing.set(key, { sites: [], via: new Set() })
|
||||
const e = missing.get(key); e.sites.push(site); e.via.add(via)
|
||||
}
|
||||
|
||||
const missing = new Map() // key -> [call sites]
|
||||
let literalCount = 0
|
||||
let dynamicCount = 0
|
||||
// ★ A BUNDLE WITH NO CALL SITES SCORES PERFECTLY HERE, AND THAT IS NOT THE SAME AS CLEAN.
|
||||
// This gate asks whether the keys a bundle NAMES exist. A bundle that names none — upg-neighbor and
|
||||
// wall-of-people-groups have no t() anywhere, per the universal-props matrix — has nothing to be
|
||||
// missing and passes in silence. Reporting only the misses would let "0 missing keys" be read as
|
||||
// "fully localized", which is the opposite of true for exactly those bundles. So participation is
|
||||
// counted and printed alongside, and the two are never merged into one number.
|
||||
const callSites = new Map() // bundle -> count
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8')
|
||||
const rel = relative(ROOT, f)
|
||||
for (const m of src.matchAll(LITERAL)) {
|
||||
const src = readFile(f)
|
||||
if (!src || !src.includes('tStatic') && !src.includes('$t(') && !src.includes(' t(')) continue
|
||||
const { full, prefixed } = aliasesIn(src)
|
||||
const names = [...full, ...prefixed.keys()].map((n) => n.replace('$', '\\$')).join('|')
|
||||
if (!names) continue
|
||||
|
||||
// FULL dotted key.
|
||||
for (const m of src.matchAll(new RegExp(`(?:^|[^\\w$.])(?:${names})\\(\\s*'([A-Za-z_][\\w]*(?:\\.[\\w-]+)+)'`, 'g'))) {
|
||||
const key = m[1]
|
||||
literalCount++
|
||||
if (KEYS.has(key)) continue
|
||||
// Only keys whose AREA is a real catalogue file are this gate's business: `props.t.total`,
|
||||
// `config.map.provider` and friends are not i18n keys and must not be reported as missing.
|
||||
const area = key.split('.')[0]
|
||||
if (!existsSync(join(EN, area + '.json'))) continue
|
||||
const line = src.slice(0, m.index).split('\n').length
|
||||
if (!missing.has(key)) missing.set(key, [])
|
||||
missing.get(key).push(`${rel}:${line}`)
|
||||
if (!AREAS.has(key.split('.')[0])) continue
|
||||
record(key, `${f}:${lineAt(src, m.index)}`, 'literal')
|
||||
}
|
||||
for (const _ of src.matchAll(DYNAMIC)) dynamicCount++
|
||||
// BARE key through a prefix helper.
|
||||
for (const [name, prefixes] of prefixed) {
|
||||
// ★ AN EMPTY ENGLISH FALLBACK IS A DELIBERATE FALLTHROUGH, NOT A MISSING KEY.
|
||||
// BpResearchHeader renders `L('reset_all', '') || tStatic('controls.reset_all') || 'Reset'`:
|
||||
// the author is saying "if this prefix has nothing, fall to the next rung", and controls.reset_all
|
||||
// does hold it. A first run of the prefix resolution reported that as a defect — a gate inventing
|
||||
// a problem out of a working ladder, which is the exact failure I am about to mail seven owners
|
||||
// about. `,''` is the author's own signal that the miss is intended.
|
||||
for (const m of src.matchAll(new RegExp(`(?:^|[^\\w$.])${name}\\(\\s*'([\\w-]+)'\\s*(,\\s*''\\s*\\))?`, 'g'))) {
|
||||
const bare = m[1]
|
||||
if (m[2]) continue
|
||||
prefixedCount++
|
||||
if (prefixes.some((p) => KEYS.has(p + '.' + bare))) continue
|
||||
record(prefixes.map((p) => p + '.' + bare).join(' | '), `${f}:${lineAt(src, m.index)}`, `${name}()`)
|
||||
}
|
||||
}
|
||||
// Genuinely DYNAMIC: a concatenation or a template literal as the first argument.
|
||||
for (const _ of src.matchAll(new RegExp(`(?:^|[^\\w$.])(?:${names})\\(\\s*(?:'[^']*'\\s*\\+|\`)`, 'g'))) dynamicCount++
|
||||
const b = f.startsWith('plugin-designs/') ? f.split('/')[1] : 'library'
|
||||
const n = (src.match(new RegExp(`(?:^|[^\\w$.])(?:${names})\\(`, 'g')) || []).length
|
||||
callSites.set(b, (callSites.get(b) || 0) + n)
|
||||
}
|
||||
|
||||
console.log(` · ${KEYS.size} English keys across ${readdirSync(EN).filter((f) => f.endsWith('.json')).length} catalogue(s); ${literalCount} literal call site(s) in ${files.length} file(s)`)
|
||||
const bundleOf = (site) => {
|
||||
const p = site.split(':')[0]
|
||||
if (p.startsWith('plugin-designs/')) return p.split('/')[1]
|
||||
return 'library'
|
||||
}
|
||||
|
||||
console.log(` · tree: ${describeTree}`)
|
||||
console.log(` · ${KEYS.size} English keys across ${catalogues.length} catalogue(s); ${literalCount} full-key and ${prefixedCount} prefixed call site(s) in ${files.length} file(s)`)
|
||||
if (dynamicCount) {
|
||||
console.log(` · ${dynamicCount} DYNAMIC key expression(s) not checked — this gate will not invent the key set a runtime expression might produce.`)
|
||||
}
|
||||
{
|
||||
const bundles = listFiles((p) => /^plugin-designs\/[^/]+\//.test(p)).map((p) => p.split('/')[1])
|
||||
const silent = [...new Set(bundles)].filter((b) => !callSites.get(b)).sort()
|
||||
if (silent.length) {
|
||||
console.log(` · ${silent.length} bundle(s) name NO catalogue key at all, so they cannot fail this gate and their pass means nothing: ${silent.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size) {
|
||||
if (BY_BUNDLE) {
|
||||
const byBundle = new Map()
|
||||
for (const [key, e] of missing) for (const s of e.sites) {
|
||||
const b = bundleOf(s)
|
||||
if (!byBundle.has(b)) byBundle.set(b, new Map())
|
||||
if (!byBundle.get(b).has(key)) byBundle.get(b).set(key, [])
|
||||
byBundle.get(b).get(key).push(s)
|
||||
}
|
||||
console.error('')
|
||||
for (const [b, keys] of [...byBundle].sort((a, c) => c[1].size - a[1].size)) {
|
||||
console.error(`── ${b} — ${keys.size} missing key(s)`)
|
||||
for (const [key, sites] of [...keys].sort()) console.error(` ${key}\n ${sites.join('\n ')}`)
|
||||
}
|
||||
}
|
||||
console.error(`\n${TAG}: FAIL — ${missing.size} key(s) are named in code and exist in NO catalogue, so every locale renders the English fallback:`)
|
||||
for (const [key, sites] of [...missing].sort()) {
|
||||
console.error(` ✗ ${key}`)
|
||||
for (const s of sites.slice(0, 4)) console.error(` ${s}`)
|
||||
if (sites.length > 4) console.error(` … and ${sites.length - 4} more`)
|
||||
if (!BY_BUNDLE) for (const [key, e] of [...missing].sort()) {
|
||||
console.error(` ✗ ${key}${e.via.has('literal') ? '' : ' (via ' + [...e.via].join(', ') + ')'}`)
|
||||
for (const s of e.sites.slice(0, 4)) console.error(` ${s}`)
|
||||
if (e.sites.length > 4) console.error(` … and ${e.sites.length - 4} more`)
|
||||
}
|
||||
console.error('\n The call sites are RIGHT; the catalogue never caught up. Add the key to')
|
||||
console.error(' library/i18n/locales/*/<area>.json in all eleven locales, keeping the English')
|
||||
|
|
@ -134,4 +257,4 @@ if (missing.size) {
|
|||
console.error(' product decision, but changing it is.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — every literal catalogue key named in code exists in English.`)
|
||||
console.log(`${TAG}: PASS — every catalogue key named in code exists in English.`)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<p class="sub">Capability 4, STACK variant — the shared <code>StackLayout</code> renders two
|
||||
elements (<code><research-dashboard-map></code> then <code><people-groups-grid></code>)
|
||||
<strong>on top of each other</strong>, each at its natural height, so you
|
||||
<strong>scroll down</strong> through both on one page (the doxa-life / nuxtinator page-flow model).
|
||||
<strong>scroll down</strong> through both on one page, as on a normal web page.
|
||||
Embed <code><stack-demo></code> anywhere and the host page provides the scroll.</p>
|
||||
<div class="host">
|
||||
<stack-demo></stack-demo>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* @element stack-demo
|
||||
* @description Groups TWO independent elements TOP-TO-BOTTOM so you can embed the pair on a page and
|
||||
* SCROLL down through all of it (the doxa-life / nuxtinator page-flow model), instead of switching
|
||||
* SCROLL down through all of it (the page-flow model of a normal host page), instead of switching
|
||||
* between them with tabs. Section 1 = the rich <research-dashboard-map> (facets + map + charts +
|
||||
* table); section 2 = the cap-2 <people-groups-grid> (live pray.doxa.life). Each renders at its
|
||||
* NATURAL height; the host page scrolls.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
|
||||
<script src="../all-people-groups.js" integrity="sha384-95cAunKQ+LvdFbL5xIHcfQChH3wZsBX2+dGJ+PpeB+/bcKyGFj/95ZhOky8h6+nD" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-SGOU2Dm5al25r7QWV/5qhkOHy9A63UjMCu3yHz7r+8gV3Rkfuu/ONqQd6zJGR2p8" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
|
||||
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
|
||||
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",
|
||||
"ways_in_are_country_level": "The ways in are chosen for the COUNTRY, and going below the border is what exposes the cost of that. Arunachal Pradesh, in the far north-east, is offered TELUGU — a South Indian language — because the curated bridge table names it for those groups and a curated bridge outranks the computed one. India's computed default is Hindi (41%, official), which is defensible nationally and still wrong for much of the north-east. This table does not fix that: it makes it visible. Until a bridge can be chosen per province, read the ways-in column as \"a language with Scripture and film somewhere in this country\", not as a recommendation for this place.",
|
||||
"ways_in": "Chosen FOR THE PROVINCE where the province can answer: the languages of groups standing in it that already have Scripture AND film, ranked by the people here who already hold them. The column says which basis was used. This replaces a country-level answer that produced a measured absurdity — Arunachal Pradesh, in the far north-east, was offered TELUGU, because the curated bridge table is keyed by country and a curated bridge outranks a computed one. A province with no tier-2 language of its own still falls back to the country answer, and ways_in_basis says so rather than hiding it. It is a NEIGHBOUR, not a recommendation: whether a group actually speaks it is a question for someone on the ground.",
|
||||
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
|
||||
},
|
||||
"columns": [
|
||||
|
|
@ -49,11 +49,36 @@
|
|||
"field": "zero_access_examples",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Nobody praying",
|
||||
"field": "nobody_praying_groups",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"title": "Of those with a prayer page",
|
||||
"field": "prayer_pages",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"title": "People nobody is praying for",
|
||||
"field": "nobody_praying_people",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"title": "Ways in",
|
||||
"field": "ways_in",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Basis",
|
||||
"field": "ways_in_basis",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Ways in (country-level)",
|
||||
"field": "ways_in_country",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Placement approximate",
|
||||
"field": "approximate",
|
||||
|
|
@ -70,7 +95,18 @@
|
|||
"zero_access_groups": 14,
|
||||
"zero_access_people": 602850,
|
||||
"zero_access_examples": "Hongjin Tai; Wenma Zhuang; Mosuo",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 45,
|
||||
"pray_first": {
|
||||
"name": "Hongjin Tai",
|
||||
"url": "https://pray.doxa.life/hongjin-tai?source=doxalife&utm_source=agwm",
|
||||
"people": 209000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 35,
|
||||
"nobody_praying_people": 1430325,
|
||||
"ways_in": "Chinese, Mandarin (63,825 here); Tai Dam (48,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -82,7 +118,18 @@
|
|||
"zero_access_groups": 7,
|
||||
"zero_access_people": 488900,
|
||||
"zero_access_examples": "Huishui Miao; Yanghuang; Cai",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 28,
|
||||
"pray_first": {
|
||||
"name": "Yanghuang",
|
||||
"url": "https://pray.doxa.life/yanghuang?source=doxalife&utm_source=agwm",
|
||||
"people": 63000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 20,
|
||||
"nobody_praying_people": 560000,
|
||||
"ways_in": "Chinese, Mandarin (332,900 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -94,7 +141,18 @@
|
|||
"zero_access_groups": 7,
|
||||
"zero_access_people": 105700,
|
||||
"zero_access_examples": "Ersu; Minyak; Namuyi",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 10,
|
||||
"pray_first": {
|
||||
"name": "Ersu",
|
||||
"url": "https://pray.doxa.life/ersu?source=doxalife&utm_source=agwm",
|
||||
"people": 48000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 8,
|
||||
"nobody_praying_people": 461600,
|
||||
"ways_in": "Tibetan (48,500 here); Chinese, Mandarin (15,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -106,7 +164,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 500,
|
||||
"zero_access_examples": "Jarawas; Onge",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Onge",
|
||||
"url": "https://pray.doxa.life/onge?source=doxalife&utm_source=agwm",
|
||||
"people": 150,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 69450,
|
||||
"ways_in": "Telugu (67,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -118,7 +187,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 600,
|
||||
"zero_access_examples": "Khamba; Na",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 7,
|
||||
"pray_first": {
|
||||
"name": "Na",
|
||||
"url": "https://pray.doxa.life/na?source=doxalife&utm_source=agwm",
|
||||
"people": 100,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 3,
|
||||
"nobody_praying_people": 7800,
|
||||
"ways_in": "Hindi (5,700 here); Assamese (2,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -130,7 +210,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 31500,
|
||||
"zero_access_examples": "Sarikoli Tajiks; Ainu",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Ainu",
|
||||
"url": "https://pray.doxa.life/ainu-china?source=doxalife&utm_source=agwm",
|
||||
"people": 11000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 215625,
|
||||
"ways_in": "Chinese, Mandarin (25 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -142,7 +233,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 150000,
|
||||
"zero_access_examples": "Eastern Xiangxi Miao; Wunai",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Wunai",
|
||||
"url": "https://pray.doxa.life/wunai?source=doxalife&utm_source=agwm",
|
||||
"people": 14000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 2,
|
||||
"nobody_praying_people": 20000,
|
||||
"ways_in": "Chinese, Mandarin (6,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -154,7 +256,18 @@
|
|||
"zero_access_groups": 2,
|
||||
"zero_access_people": 12700,
|
||||
"zero_access_examples": "Tongren Bonan; Wutun",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": {
|
||||
"name": "Wutun",
|
||||
"url": "https://pray.doxa.life/wutun?source=doxalife&utm_source=agwm",
|
||||
"people": 3800,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 3800,
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -166,7 +279,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 8600,
|
||||
"zero_access_examples": "Malai Pandaram",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 66,
|
||||
"pray_first": {
|
||||
"name": "Malai Pandaram",
|
||||
"url": "https://pray.doxa.life/malai-pandaram?source=doxalife&utm_source=agwm",
|
||||
"people": 8600,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 53,
|
||||
"nobody_praying_people": 1152750,
|
||||
"ways_in": "Malayalam (2,629,525 here); Kannada (38,200 here); Tamil (33,150 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -178,7 +302,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 5000,
|
||||
"zero_access_examples": "Jadupetia",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Jadupetia",
|
||||
"url": "https://pray.doxa.life/jadupetia?source=doxalife&utm_source=agwm",
|
||||
"people": 5000,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 5,
|
||||
"nobody_praying_people": 41900,
|
||||
"ways_in": "Hindi (495,000 here); Bengali (164,200 here); Urdu (94,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -190,7 +325,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 750,
|
||||
"zero_access_examples": "Yerong",
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Yerong",
|
||||
"url": "https://pray.doxa.life/yerong?source=doxalife&utm_source=agwm",
|
||||
"people": 750,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 51150,
|
||||
"ways_in": "Chinese, Mandarin (50,400 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -202,7 +348,18 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 1700,
|
||||
"zero_access_examples": "Koiren",
|
||||
"ways_in": "Telugu",
|
||||
"prayer_pages": 3,
|
||||
"pray_first": {
|
||||
"name": "Koiren",
|
||||
"url": "https://pray.doxa.life/koiren?source=doxalife&utm_source=agwm",
|
||||
"people": 1700,
|
||||
"has_nothing": true
|
||||
},
|
||||
"nobody_praying_groups": 3,
|
||||
"nobody_praying_people": 112400,
|
||||
"ways_in": "Meitei (319,700 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "Telugu",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -214,7 +371,13 @@
|
|||
"zero_access_groups": 1,
|
||||
"zero_access_people": 170000,
|
||||
"zero_access_examples": "Buryat",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Chinese, Mandarin",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "Chinese, Mandarin",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -226,7 +389,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 80,
|
||||
"pray_first": {
|
||||
"name": "Bairagi (Gujarati)",
|
||||
"url": "https://pray.doxa.life/bairagi-gujarati?source=doxalife&utm_source=agwm",
|
||||
"people": 510000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 59,
|
||||
"nobody_praying_people": 1429045,
|
||||
"ways_in": "Gujarati (1,874,475 here); Urdu (123,200 here); Marathi (70,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -238,7 +412,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 76,
|
||||
"pray_first": {
|
||||
"name": "Aiyarakulu",
|
||||
"url": "https://pray.doxa.life/aiyarakulu?source=doxalife&utm_source=agwm",
|
||||
"people": 688000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 64,
|
||||
"nobody_praying_people": 3818505,
|
||||
"ways_in": "Telugu (3,561,375 here); Kannada (509,500 here); Odia (378,650 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -250,7 +435,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 75,
|
||||
"pray_first": {
|
||||
"name": "Nai (Kannada)",
|
||||
"url": "https://pray.doxa.life/nai-kannada?source=doxalife&utm_source=agwm",
|
||||
"people": 336000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 55,
|
||||
"nobody_praying_people": 2569950,
|
||||
"ways_in": "Kannada (2,277,625 here); Telugu (666,800 here); Urdu (261,625 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -262,7 +458,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 64,
|
||||
"pray_first": {
|
||||
"name": "Tondman",
|
||||
"url": "https://pray.doxa.life/tondman?source=doxalife&utm_source=agwm",
|
||||
"people": 555000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 50,
|
||||
"nobody_praying_people": 2144700,
|
||||
"ways_in": "Tamil (1,646,150 here); Telugu (823,600 here); Malayalam (224,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -274,7 +481,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 54,
|
||||
"pray_first": {
|
||||
"name": "Rana",
|
||||
"url": "https://pray.doxa.life/rana?source=doxalife&utm_source=agwm",
|
||||
"people": 76500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 41,
|
||||
"nobody_praying_people": 523900,
|
||||
"ways_in": "Odia (1,197,550 here); Bengali (31,500 here); Maithili (17,900 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -286,7 +504,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 52,
|
||||
"pray_first": {
|
||||
"name": "Lonar",
|
||||
"url": "https://pray.doxa.life/lonar?source=doxalife&utm_source=agwm",
|
||||
"people": 427000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 45,
|
||||
"nobody_praying_people": 1967000,
|
||||
"ways_in": "Marathi (2,144,775 here); Telugu (247,225 here); Urdu (181,300 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -298,7 +527,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 45,
|
||||
"pray_first": {
|
||||
"name": "Van Baoriya",
|
||||
"url": "https://pray.doxa.life/van-baoriya?source=doxalife&utm_source=agwm",
|
||||
"people": 442000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 33,
|
||||
"nobody_praying_people": 1549725,
|
||||
"ways_in": "Hindi (61,400 here); Urdu (21,175 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -310,7 +550,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 27,
|
||||
"pray_first": {
|
||||
"name": "Bari (Hindi)",
|
||||
"url": "https://pray.doxa.life/bari-hindi?source=doxalife&utm_source=agwm",
|
||||
"people": 405000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 23,
|
||||
"nobody_praying_people": 1217800,
|
||||
"ways_in": "Hindi (2,060,800 here); Chhattisgarhi (234,000 here); Marathi (73,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -322,7 +573,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 32,
|
||||
"pray_first": {
|
||||
"name": "Bania (Dogri)",
|
||||
"url": "https://pray.doxa.life/bania-dogri?source=doxalife&utm_source=agwm",
|
||||
"people": 146000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 25,
|
||||
"nobody_praying_people": 497665,
|
||||
"ways_in": "Hindi (30,500 here); Urdu (250 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -334,7 +596,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 28,
|
||||
"pray_first": {
|
||||
"name": "Khairwar (Bengali)",
|
||||
"url": "https://pray.doxa.life/khairwar-bengali?source=doxalife&utm_source=agwm",
|
||||
"people": 166000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 16,
|
||||
"nobody_praying_people": 381500,
|
||||
"ways_in": "Bengali (2,146,100 here); Sunwar (38,000 here); Nepali (19,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -346,7 +619,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 22,
|
||||
"pray_first": {
|
||||
"name": "Bolla",
|
||||
"url": "https://pray.doxa.life/bolla?source=doxalife&utm_source=agwm",
|
||||
"people": 244000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 16,
|
||||
"nobody_praying_people": 1155150,
|
||||
"ways_in": "Telugu (1,342,400 here); Marathi (243,000 here); Hindi (73,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -358,7 +642,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 24,
|
||||
"pray_first": {
|
||||
"name": "Badhai (Chhattisgarhi)",
|
||||
"url": "https://pray.doxa.life/badhai-chhattisgarhi?source=doxalife&utm_source=agwm",
|
||||
"people": 843000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 20,
|
||||
"nobody_praying_people": 1818200,
|
||||
"ways_in": "Chhattisgarhi (1,411,850 here); Halbi (39,200 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -370,7 +665,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 19,
|
||||
"pray_first": {
|
||||
"name": "Bargujar",
|
||||
"url": "https://pray.doxa.life/bargujar?source=doxalife&utm_source=agwm",
|
||||
"people": 159000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 12,
|
||||
"nobody_praying_people": 410150,
|
||||
"ways_in": "Hindi (332,550 here); Urdu (227,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -382,7 +688,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"prayer_pages": 18,
|
||||
"pray_first": {
|
||||
"name": "Pangwala",
|
||||
"url": "https://pray.doxa.life/pangwala?source=doxalife&utm_source=agwm",
|
||||
"people": 24000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 13,
|
||||
"nobody_praying_people": 75470,
|
||||
"ways_in": "",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -394,7 +711,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 17,
|
||||
"pray_first": {
|
||||
"name": "Tilli",
|
||||
"url": "https://pray.doxa.life/tilli?source=doxalife&utm_source=agwm",
|
||||
"people": 150000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 14,
|
||||
"nobody_praying_people": 400200,
|
||||
"ways_in": "Hindi (164,000 here); Urdu (82,800 here); Maithili (54,700 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -406,7 +734,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 14,
|
||||
"pray_first": {
|
||||
"name": "Namasudra (Assamese)",
|
||||
"url": "https://pray.doxa.life/namasudra-assamese?source=doxalife&utm_source=agwm",
|
||||
"people": 827000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 11,
|
||||
"nobody_praying_people": 1303400,
|
||||
"ways_in": "Assamese (1,459,800 here); Bengali (910,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -418,7 +757,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 6,
|
||||
"pray_first": {
|
||||
"name": "Za",
|
||||
"url": "https://pray.doxa.life/za?source=doxalife&utm_source=agwm",
|
||||
"people": 3400,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 6550,
|
||||
"ways_in": "Tibetan (85,100 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -430,7 +780,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 9,
|
||||
"pray_first": {
|
||||
"name": "Mahisyadas",
|
||||
"url": "https://pray.doxa.life/mahisyadas?source=doxalife&utm_source=agwm",
|
||||
"people": 190000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 9,
|
||||
"nobody_praying_people": 280500,
|
||||
"ways_in": "Bengali (695,400 here); Odia (3,100 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -442,7 +803,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 7,
|
||||
"pray_first": {
|
||||
"name": "Tagewale",
|
||||
"url": "https://pray.doxa.life/tagewale?source=doxalife&utm_source=agwm",
|
||||
"people": 7000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 6,
|
||||
"nobody_praying_people": 13875,
|
||||
"ways_in": "Hindi (7,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -454,7 +826,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 6,
|
||||
"pray_first": {
|
||||
"name": "Campa",
|
||||
"url": "https://pray.doxa.life/campa?source=doxalife&utm_source=agwm",
|
||||
"people": 7500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 15500,
|
||||
"ways_in": "Ladakhi (11,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -466,7 +849,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 5,
|
||||
"pray_first": {
|
||||
"name": "Gabit",
|
||||
"url": "https://pray.doxa.life/gabit?source=doxalife&utm_source=agwm",
|
||||
"people": 210000,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 252600,
|
||||
"ways_in": "Marathi (4,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -478,7 +872,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Sonavar",
|
||||
"url": "https://pray.doxa.life/sonavar?source=doxalife&utm_source=agwm",
|
||||
"people": 20500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 4,
|
||||
"nobody_praying_people": 28625,
|
||||
"ways_in": "Urdu (20,500 here); Tamil (13,950 here); French (6,700 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -490,7 +895,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Lakhera (Haryanvi)",
|
||||
"url": "https://pray.doxa.life/lakhera-haryanvi?source=doxalife&utm_source=agwm",
|
||||
"people": 6700,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 6700,
|
||||
"ways_in": "Punjabi (41,000 here); Haryanvi (26,200 here); Hindi (2,300 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -502,7 +918,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 4,
|
||||
"pray_first": {
|
||||
"name": "Racband",
|
||||
"url": "https://pray.doxa.life/racband?source=doxalife&utm_source=agwm",
|
||||
"people": 3600,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 3,
|
||||
"nobody_praying_people": 7100,
|
||||
"ways_in": "Punjabi (10,100 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -514,7 +941,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Chinese, Mandarin (16,000 here); Tibetan (5,400 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -526,7 +959,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -538,7 +977,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 2,
|
||||
"pray_first": {
|
||||
"name": "Dalu",
|
||||
"url": "https://pray.doxa.life/dalu-india?source=doxalife&utm_source=agwm",
|
||||
"people": 16500,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 2,
|
||||
"nobody_praying_people": 17150,
|
||||
"ways_in": "Bengali (16,500 here); Assamese (650 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -550,7 +1000,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 2,
|
||||
"pray_first": {
|
||||
"name": "Olot",
|
||||
"url": "https://pray.doxa.life/olot?source=doxalife&utm_source=agwm",
|
||||
"people": 2400,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 2400,
|
||||
"ways_in": "Chinese, Mandarin (2,600 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -562,7 +1023,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": {
|
||||
"name": "Khatbune",
|
||||
"url": "https://pray.doxa.life/khatbune?source=doxalife&utm_source=agwm",
|
||||
"people": 1200,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 1200,
|
||||
"ways_in": "Punjabi (1,200 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -574,7 +1046,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Punjabi (34,000 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -586,7 +1064,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Maldivian (74,500 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -598,7 +1082,13 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"ways_in": "",
|
||||
"prayer_pages": 0,
|
||||
"pray_first": null,
|
||||
"nobody_praying_groups": 0,
|
||||
"nobody_praying_people": 0,
|
||||
"ways_in": "Gujarati (125 here)",
|
||||
"ways_in_basis": "in this province",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
},
|
||||
{
|
||||
|
|
@ -610,7 +1100,18 @@
|
|||
"zero_access_groups": 0,
|
||||
"zero_access_people": 0,
|
||||
"zero_access_examples": "",
|
||||
"prayer_pages": 1,
|
||||
"pray_first": {
|
||||
"name": "Yakha",
|
||||
"url": "https://pray.doxa.life/yakha?source=doxalife&utm_source=agwm",
|
||||
"people": 7400,
|
||||
"has_nothing": false
|
||||
},
|
||||
"nobody_praying_groups": 1,
|
||||
"nobody_praying_people": 7400,
|
||||
"ways_in": "",
|
||||
"ways_in_basis": "country-level — no local language here has both Scripture and film",
|
||||
"ways_in_country": "",
|
||||
"approximate": true
|
||||
}
|
||||
],
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -43,7 +43,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-aFfWPXUQqu/JmYKiT5gUT1t/TuyQ9sq8CTubsVNVmDgxIeLx38I7A324Q3NLOc+o" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -43,7 +43,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-aFfWPXUQqu/JmYKiT5gUT1t/TuyQ9sq8CTubsVNVmDgxIeLx38I7A324Q3NLOc+o" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -43,7 +43,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-aFfWPXUQqu/JmYKiT5gUT1t/TuyQ9sq8CTubsVNVmDgxIeLx38I7A324Q3NLOc+o" crossorigin="anonymous"></script>
|
||||
<script src="../big-picture.js" integrity="sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-09q5YFCUjK9KrrxsBnMa+UBkpmpzTQ7LUGmZto3KC+8yjicHgP68O4rOYxcIOlU+" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-Ik4dBvh88t0S21pog1AfTfbFlTfFOuplvtc4N7KeQYmhxJNVD/f0PFAEYKOUlM1s" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-09q5YFCUjK9KrrxsBnMa+UBkpmpzTQ7LUGmZto3KC+8yjicHgP68O4rOYxcIOlU+" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-Ik4dBvh88t0S21pog1AfTfbFlTfFOuplvtc4N7KeQYmhxJNVD/f0PFAEYKOUlM1s" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-09q5YFCUjK9KrrxsBnMa+UBkpmpzTQ7LUGmZto3KC+8yjicHgP68O4rOYxcIOlU+" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-Ik4dBvh88t0S21pog1AfTfbFlTfFOuplvtc4N7KeQYmhxJNVD/f0PFAEYKOUlM1s" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-zQzZjEy+TDUJS+j76uunsBHQyDGhlrKktpSWFDtkFMa3gdPEhPiahGJd/R4rwhv/" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-qu854maZR+plzMvZ7VmIoJu5Y3acmyAFOnl0AN/1lYGOqVCNrHnagSjphPNdeG9l" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-zQzZjEy+TDUJS+j76uunsBHQyDGhlrKktpSWFDtkFMa3gdPEhPiahGJd/R4rwhv/" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-qu854maZR+plzMvZ7VmIoJu5Y3acmyAFOnl0AN/1lYGOqVCNrHnagSjphPNdeG9l" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-zQzZjEy+TDUJS+j76uunsBHQyDGhlrKktpSWFDtkFMa3gdPEhPiahGJd/R4rwhv/" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-qu854maZR+plzMvZ7VmIoJu5Y3acmyAFOnl0AN/1lYGOqVCNrHnagSjphPNdeG9l" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -47,7 +47,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-N6PZWvL+Hi0th2zzlBMHA6Zy+vQ8wESEGxRuIVW2J4z5owLqtYtEDZazVSuopP1K" crossorigin="anonymous"></script>
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-aEXRsVorVuFmdtMLGu09mEj6odlTMSYIEjfoBPXi3B1c3cFLlcHJDi/pbgbTBtuG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,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-N6PZWvL+Hi0th2zzlBMHA6Zy+vQ8wESEGxRuIVW2J4z5owLqtYtEDZazVSuopP1K" crossorigin="anonymous"></script>
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-aEXRsVorVuFmdtMLGu09mEj6odlTMSYIEjfoBPXi3B1c3cFLlcHJDi/pbgbTBtuG" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
|
||||
<script src="../demo-map.js" integrity="sha384-A9PZJ+05/bK+OR/dkCTZe/U2qoi6Vanl9rsL4IgHCIR11VkHH2xJol+n9DB5X3tB" crossorigin="anonymous"></script>
|
||||
<script src="../demo-map.js" integrity="sha384-kDD+fxrCLrQot3APFqTFz/X8207atst/kstyYS5NpsgfRUuNpXiEDLvswX8wVEQA" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
|
||||
<script src="../../demo-map.js" integrity="sha384-A9PZJ+05/bK+OR/dkCTZe/U2qoi6Vanl9rsL4IgHCIR11VkHH2xJol+n9DB5X3tB" crossorigin="anonymous"></script>
|
||||
<script src="../../demo-map.js" integrity="sha384-kDD+fxrCLrQot3APFqTFz/X8207atst/kstyYS5NpsgfRUuNpXiEDLvswX8wVEQA" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-ZdZNtw5tcs6hvlrye+/KEPQ+S4AcPtLTnwcpMbQPAYAweYo6C61qNea8oNTSxY06" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-fpwC0zTBhbE2VKKQwhs9q/81s/f0sTm5kVtkwiSA6mUHjNJTzS7go+dGxdSqEx1C" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-ZdZNtw5tcs6hvlrye+/KEPQ+S4AcPtLTnwcpMbQPAYAweYo6C61qNea8oNTSxY06" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-fpwC0zTBhbE2VKKQwhs9q/81s/f0sTm5kVtkwiSA6mUHjNJTzS7go+dGxdSqEx1C" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"bundle": "all-people-groups",
|
||||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"output": "dx-maps/all-people-groups/all-people-groups.js",
|
||||
"integrity": "sha384-95cAunKQ+LvdFbL5xIHcfQChH3wZsBX2+dGJ+PpeB+/bcKyGFj/95ZhOky8h6+nD",
|
||||
"integrity": "sha384-SGOU2Dm5al25r7QWV/5qhkOHy9A63UjMCu3yHz7r+8gV3Rkfuu/ONqQd6zJGR2p8",
|
||||
"elements": [
|
||||
"all-people-groups"
|
||||
],
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-95cAunKQ+LvdFbL5xIHcfQChH3wZsBX2+dGJ+PpeB+/bcKyGFj/95ZhOky8h6+nD\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-SGOU2Dm5al25r7QWV/5qhkOHy9A63UjMCu3yHz7r+8gV3Rkfuu/ONqQd6zJGR2p8\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
},
|
||||
"bundle": "all-people-groups"
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"bundle": "big-picture",
|
||||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"output": "dx-maps/big-picture/big-picture.js",
|
||||
"integrity": "sha384-aFfWPXUQqu/JmYKiT5gUT1t/TuyQ9sq8CTubsVNVmDgxIeLx38I7A324Q3NLOc+o",
|
||||
"integrity": "sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP",
|
||||
"elements": [
|
||||
"bp-big-picture",
|
||||
"bp-research-header",
|
||||
|
|
@ -60,7 +60,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-aFfWPXUQqu/JmYKiT5gUT1t/TuyQ9sq8CTubsVNVmDgxIeLx38I7A324Q3NLOc+o\" 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-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP\" 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": [
|
||||
{
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"bundle": "cities-internet",
|
||||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"output": "dx-maps/cities-internet/cities-internet.js",
|
||||
"integrity": "sha384-09q5YFCUjK9KrrxsBnMa+UBkpmpzTQ7LUGmZto3KC+8yjicHgP68O4rOYxcIOlU+",
|
||||
"integrity": "sha384-Ik4dBvh88t0S21pog1AfTfbFlTfFOuplvtc4N7KeQYmhxJNVD/f0PFAEYKOUlM1s",
|
||||
"elements": [
|
||||
"cities-internet-map"
|
||||
],
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/connectivity-list/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-09q5YFCUjK9KrrxsBnMa+UBkpmpzTQ7LUGmZto3KC+8yjicHgP68O4rOYxcIOlU+\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Ik4dBvh88t0S21pog1AfTfbFlTfFOuplvtc4N7KeQYmhxJNVD/f0PFAEYKOUlM1s\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/connectivity-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-09q5YFCUjK9KrrxsBnMa+UBkpmpzTQ7LUGmZto3KC+8yjicHgP68O4rOYxcIOlU+\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Ik4dBvh88t0S21pog1AfTfbFlTfFOuplvtc4N7KeQYmhxJNVD/f0PFAEYKOUlM1s\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -148,7 +148,7 @@
|
|||
"scriptUrl": "./cities-internet/cities-internet.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./cities-internet/data-tables/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-09q5YFCUjK9KrrxsBnMa+UBkpmpzTQ7LUGmZto3KC+8yjicHgP68O4rOYxcIOlU+\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-Ik4dBvh88t0S21pog1AfTfbFlTfFOuplvtc4N7KeQYmhxJNVD/f0PFAEYKOUlM1s\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
"bundle": "country-posters",
|
||||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"output": "dx-maps/country-posters/country-posters.js",
|
||||
"integrity": "sha384-zQzZjEy+TDUJS+j76uunsBHQyDGhlrKktpSWFDtkFMa3gdPEhPiahGJd/R4rwhv/",
|
||||
"integrity": "sha384-qu854maZR+plzMvZ7VmIoJu5Y3acmyAFOnl0AN/1lYGOqVCNrHnagSjphPNdeG9l",
|
||||
"elements": [
|
||||
"country-posters-map"
|
||||
],
|
||||
|
|
@ -177,7 +177,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-zQzZjEy+TDUJS+j76uunsBHQyDGhlrKktpSWFDtkFMa3gdPEhPiahGJd/R4rwhv/\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-qu854maZR+plzMvZ7VmIoJu5Y3acmyAFOnl0AN/1lYGOqVCNrHnagSjphPNdeG9l\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-poster-wall/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=\"./country-posters/country-posters.js\" integrity=\"sha384-zQzZjEy+TDUJS+j76uunsBHQyDGhlrKktpSWFDtkFMa3gdPEhPiahGJd/R4rwhv/\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-qu854maZR+plzMvZ7VmIoJu5Y3acmyAFOnl0AN/1lYGOqVCNrHnagSjphPNdeG9l\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-zQzZjEy+TDUJS+j76uunsBHQyDGhlrKktpSWFDtkFMa3gdPEhPiahGJd/R4rwhv/\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-qu854maZR+plzMvZ7VmIoJu5Y3acmyAFOnl0AN/1lYGOqVCNrHnagSjphPNdeG9l\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-N6PZWvL+Hi0th2zzlBMHA6Zy+vQ8wESEGxRuIVW2J4z5owLqtYtEDZazVSuopP1K",
|
||||
"integrity": "sha384-aEXRsVorVuFmdtMLGu09mEj6odlTMSYIEjfoBPXi3B1c3cFLlcHJDi/pbgbTBtuG",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -234,7 +234,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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-N6PZWvL+Hi0th2zzlBMHA6Zy+vQ8wESEGxRuIVW2J4z5owLqtYtEDZazVSuopP1K\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-aEXRsVorVuFmdtMLGu09mEj6odlTMSYIEjfoBPXi3B1c3cFLlcHJDi/pbgbTBtuG\" 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": [
|
||||
{
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
"bundle": "demo-map",
|
||||
"scriptUrl": "./demo-map/demo-map.js",
|
||||
"output": "dx-maps/demo-map/demo-map.js",
|
||||
"integrity": "sha384-A9PZJ+05/bK+OR/dkCTZe/U2qoi6Vanl9rsL4IgHCIR11VkHH2xJol+n9DB5X3tB",
|
||||
"integrity": "sha384-kDD+fxrCLrQot3APFqTFz/X8207atst/kstyYS5NpsgfRUuNpXiEDLvswX8wVEQA",
|
||||
"elements": [
|
||||
"demo-map"
|
||||
],
|
||||
|
|
@ -274,7 +274,7 @@
|
|||
"scriptUrl": "./demo-map/demo-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./demo-map/demo-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-A9PZJ+05/bK+OR/dkCTZe/U2qoi6Vanl9rsL4IgHCIR11VkHH2xJol+n9DB5X3tB\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-kDD+fxrCLrQot3APFqTFz/X8207atst/kstyYS5NpsgfRUuNpXiEDLvswX8wVEQA\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -301,7 +301,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-ZdZNtw5tcs6hvlrye+/KEPQ+S4AcPtLTnwcpMbQPAYAweYo6C61qNea8oNTSxY06",
|
||||
"integrity": "sha384-fpwC0zTBhbE2VKKQwhs9q/81s/f0sTm5kVtkwiSA6mUHjNJTzS7go+dGxdSqEx1C",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -326,7 +326,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/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=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-ZdZNtw5tcs6hvlrye+/KEPQ+S4AcPtLTnwcpMbQPAYAweYo6C61qNea8oNTSxY06\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-fpwC0zTBhbE2VKKQwhs9q/81s/f0sTm5kVtkwiSA6mUHjNJTzS7go+dGxdSqEx1C\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -353,7 +353,7 @@
|
|||
"bundle": "doxa-research-map",
|
||||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
|
||||
"integrity": "sha384-HpQG4Fj6sgsMhugde9/B30Hjs959RizGYvEXSHT1q2o1EtPIEInNUR26vTZgj9r0",
|
||||
"integrity": "sha384-R5R8R1AWeOs/yz/0q2U/ahJkgcbu+8//vyw3ZfvYVYFA3O1xiULksIhA7975VeOm",
|
||||
"elements": [
|
||||
"doxa-research-map"
|
||||
],
|
||||
|
|
@ -369,7 +369,7 @@
|
|||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-HpQG4Fj6sgsMhugde9/B30Hjs959RizGYvEXSHT1q2o1EtPIEInNUR26vTZgj9r0\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-R5R8R1AWeOs/yz/0q2U/ahJkgcbu+8//vyw3ZfvYVYFA3O1xiULksIhA7975VeOm\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -395,7 +395,7 @@
|
|||
"bundle": "doxa-simple-map",
|
||||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
|
||||
"integrity": "sha384-/cCytJvG3SP1z+z9F5hNR1chtv7aXYojtDyNMmMMyLCMBk2WttzYZysKxrQ1gNOc",
|
||||
"integrity": "sha384-hwuO0pWvI3B6X2EmGD+DI/xt6pRktnmNw83Mg9jG9OKgMHv/TTRKdX2hjVmtD2Wp",
|
||||
"elements": [
|
||||
"doxa-map",
|
||||
"doxa-simple-map"
|
||||
|
|
@ -412,7 +412,7 @@
|
|||
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-/cCytJvG3SP1z+z9F5hNR1chtv7aXYojtDyNMmMMyLCMBk2WttzYZysKxrQ1gNOc\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-hwuO0pWvI3B6X2EmGD+DI/xt6pRktnmNw83Mg9jG9OKgMHv/TTRKdX2hjVmtD2Wp\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -578,7 +578,7 @@
|
|||
"bundle": "gospel-resources",
|
||||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"output": "dx-maps/gospel-resources/gospel-resources.js",
|
||||
"integrity": "sha384-rchv9WBAm3sGgFXtL2UG163LLT5bxQCyegj7Z1opKKYnivYlpyI1lPIoLL1zNWfu",
|
||||
"integrity": "sha384-9Rxp4djqi3uUYXmDSi1TEQfZaZSFOUS06au57murAD6LEJcestGd+/VBnrI3poEh",
|
||||
"elements": [
|
||||
"gospel-resources-map"
|
||||
],
|
||||
|
|
@ -598,7 +598,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/charts/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-rchv9WBAm3sGgFXtL2UG163LLT5bxQCyegj7Z1opKKYnivYlpyI1lPIoLL1zNWfu\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-9Rxp4djqi3uUYXmDSi1TEQfZaZSFOUS06au57murAD6LEJcestGd+/VBnrI3poEh\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -612,7 +612,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/data-tables/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-rchv9WBAm3sGgFXtL2UG163LLT5bxQCyegj7Z1opKKYnivYlpyI1lPIoLL1zNWfu\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-9Rxp4djqi3uUYXmDSi1TEQfZaZSFOUS06au57murAD6LEJcestGd+/VBnrI3poEh\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -626,7 +626,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-rchv9WBAm3sGgFXtL2UG163LLT5bxQCyegj7Z1opKKYnivYlpyI1lPIoLL1zNWfu\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-9Rxp4djqi3uUYXmDSi1TEQfZaZSFOUS06au57murAD6LEJcestGd+/VBnrI3poEh\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -640,7 +640,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/resources-list/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-rchv9WBAm3sGgFXtL2UG163LLT5bxQCyegj7Z1opKKYnivYlpyI1lPIoLL1zNWfu\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-9Rxp4djqi3uUYXmDSi1TEQfZaZSFOUS06au57murAD6LEJcestGd+/VBnrI3poEh\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -654,7 +654,7 @@
|
|||
"scriptUrl": "./gospel-resources/gospel-resources.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-rchv9WBAm3sGgFXtL2UG163LLT5bxQCyegj7Z1opKKYnivYlpyI1lPIoLL1zNWfu\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-9Rxp4djqi3uUYXmDSi1TEQfZaZSFOUS06au57murAD6LEJcestGd+/VBnrI3poEh\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -663,7 +663,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-+dOgeGpDs6X2yHHUmmk6MBNlN33i8u62453SczDMh2VG/wMbrgf7ttZIXMMfR1hp",
|
||||
"integrity": "sha384-MJzZo24COpE0rbV9u5xWWVe82Pme+oXFHyIg5CjVPHitCgTDQKmboXedjzxKzLwU",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -683,7 +683,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-+dOgeGpDs6X2yHHUmmk6MBNlN33i8u62453SczDMh2VG/wMbrgf7ttZIXMMfR1hp\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-MJzZo24COpE0rbV9u5xWWVe82Pme+oXFHyIg5CjVPHitCgTDQKmboXedjzxKzLwU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -697,7 +697,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-+dOgeGpDs6X2yHHUmmk6MBNlN33i8u62453SczDMh2VG/wMbrgf7ttZIXMMfR1hp\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-MJzZo24COpE0rbV9u5xWWVe82Pme+oXFHyIg5CjVPHitCgTDQKmboXedjzxKzLwU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -711,7 +711,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-+dOgeGpDs6X2yHHUmmk6MBNlN33i8u62453SczDMh2VG/wMbrgf7ttZIXMMfR1hp\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-MJzZo24COpE0rbV9u5xWWVe82Pme+oXFHyIg5CjVPHitCgTDQKmboXedjzxKzLwU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -720,7 +720,7 @@
|
|||
"bundle": "people-groups-grid",
|
||||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"output": "dx-maps/people-groups-grid/people-groups-grid.js",
|
||||
"integrity": "sha384-dgRIP0FFdZjyJEGvFECVTsM/XfUqaL0mpKjwGN/sK7ymtz143AGFbniLL/0ffaZK",
|
||||
"integrity": "sha384-rgMFgukVmFCDPWYtJdcI0zoiKic20G+OA3OBrZcI2IZHNpC6uJzxjHQ/Ogj1aUsD",
|
||||
"elements": [
|
||||
"people-groups-grid"
|
||||
],
|
||||
|
|
@ -736,7 +736,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-dgRIP0FFdZjyJEGvFECVTsM/XfUqaL0mpKjwGN/sK7ymtz143AGFbniLL/0ffaZK\" 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-rgMFgukVmFCDPWYtJdcI0zoiKic20G+OA3OBrZcI2IZHNpC6uJzxjHQ/Ogj1aUsD\" 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": [
|
||||
{
|
||||
|
|
@ -781,7 +781,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "dx-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-Hm8H3zmSG4Z6QE3U5KOK4yQITwoHb6Pl6EIj3bSWUuuMs2YGcrqCLeLM8Y6EHE1o",
|
||||
"integrity": "sha384-Jc2znVVtfkv01BBMY6IpyHr/W2r1JtqflWXd/s8LWPfJ+Ge62BgIK2vUbmvtvZH+",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -809,7 +809,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/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=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-Hm8H3zmSG4Z6QE3U5KOK4yQITwoHb6Pl6EIj3bSWUuuMs2YGcrqCLeLM8Y6EHE1o\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-Jc2znVVtfkv01BBMY6IpyHr/W2r1JtqflWXd/s8LWPfJ+Ge62BgIK2vUbmvtvZH+\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -836,7 +836,7 @@
|
|||
"bundle": "pplr-data-maps",
|
||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
|
||||
"integrity": "sha384-3oCE0lJihFWJ8vNjpj6yCaUDuoYkn+VG0gO8NeF8OFux5jtRQYqufmUuvsHvOF46",
|
||||
"integrity": "sha384-vlRYtuZbl8uLUdt3BdNYUz40tJIOHtMquV6MvU9oJsWN4cz26mvqvmr5J9IR3Xpw",
|
||||
"elements": [
|
||||
"pplr-data-maps"
|
||||
],
|
||||
|
|
@ -852,7 +852,7 @@
|
|||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-3oCE0lJihFWJ8vNjpj6yCaUDuoYkn+VG0gO8NeF8OFux5jtRQYqufmUuvsHvOF46\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-vlRYtuZbl8uLUdt3BdNYUz40tJIOHtMquV6MvU9oJsWN4cz26mvqvmr5J9IR3Xpw\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
|
||||
},
|
||||
"bundle": "pplr-data-maps"
|
||||
}
|
||||
|
|
@ -887,7 +887,7 @@
|
|||
"bundle": "regions-dashboard",
|
||||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
|
||||
"integrity": "sha384-F9f8MNvNSNXIWOnTvUP4z0i9wHQ8n3VvDlWDmXA9qhcsaHqTUr2QhM/w5b7CirMD",
|
||||
"integrity": "sha384-SnDDRSZmhUwrpNq6xopiym+7UVsMBxZQak3RVTox7+sZjZkMhmNAHK7y7eTyhTM2",
|
||||
"elements": [
|
||||
"regions-dashboard"
|
||||
],
|
||||
|
|
@ -907,7 +907,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-F9f8MNvNSNXIWOnTvUP4z0i9wHQ8n3VvDlWDmXA9qhcsaHqTUr2QhM/w5b7CirMD\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-SnDDRSZmhUwrpNq6xopiym+7UVsMBxZQak3RVTox7+sZjZkMhmNAHK7y7eTyhTM2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -921,7 +921,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-list/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=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-F9f8MNvNSNXIWOnTvUP4z0i9wHQ8n3VvDlWDmXA9qhcsaHqTUr2QhM/w5b7CirMD\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-SnDDRSZmhUwrpNq6xopiym+7UVsMBxZQak3RVTox7+sZjZkMhmNAHK7y7eTyhTM2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -935,7 +935,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-F9f8MNvNSNXIWOnTvUP4z0i9wHQ8n3VvDlWDmXA9qhcsaHqTUr2QhM/w5b7CirMD\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-SnDDRSZmhUwrpNq6xopiym+7UVsMBxZQak3RVTox7+sZjZkMhmNAHK7y7eTyhTM2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -944,7 +944,7 @@
|
|||
"bundle": "research-dashboard",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"output": "dx-maps/research-dashboard/research-dashboard.js",
|
||||
"integrity": "sha384-dO4ODgsIYfHAOjuXqTW0A+xtCnlslCg03Ukh4NEB2tlJrLFUueZ+LbtX/+fBo3F8",
|
||||
"integrity": "sha384-1gpwTWBMcmXFqeNMcuUOcke40VSJ0kgW8n73UlvoMw/p6ElHD6wsdzAW2/R4sR7h",
|
||||
"elements": [
|
||||
"research-dashboard-map"
|
||||
],
|
||||
|
|
@ -960,7 +960,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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-dO4ODgsIYfHAOjuXqTW0A+xtCnlslCg03Ukh4NEB2tlJrLFUueZ+LbtX/+fBo3F8\" 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": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-1gpwTWBMcmXFqeNMcuUOcke40VSJ0kgW8n73UlvoMw/p6ElHD6wsdzAW2/R4sR7h\" 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"
|
||||
}
|
||||
|
|
@ -970,7 +970,7 @@
|
|||
"bundle": "research-map-plus-grid",
|
||||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"output": "dx-maps/research-map-plus-grid/research-map-plus-grid.js",
|
||||
"integrity": "sha384-MZuYms3ihyjWC7Qf946V1ykPMoKrvBxanLdNJjadVwZ7jAJNQw5MHXRrKi1WXChm",
|
||||
"integrity": "sha384-mQ5oaa4Ew2CSwX4Jt1zM2M+rLZfRS1TA/0irMWeoJmf+e+0eYIwFTEgUCaYrTN1n",
|
||||
"elements": [
|
||||
"research-map-plus-grid"
|
||||
],
|
||||
|
|
@ -990,7 +990,7 @@
|
|||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-map-plus-grid/research-map-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=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-MZuYms3ihyjWC7Qf946V1ykPMoKrvBxanLdNJjadVwZ7jAJNQw5MHXRrKi1WXChm\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-mQ5oaa4Ew2CSwX4Jt1zM2M+rLZfRS1TA/0irMWeoJmf+e+0eYIwFTEgUCaYrTN1n\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1058,7 +1058,7 @@
|
|||
"bundle": "upg-neighbor",
|
||||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
|
||||
"integrity": "sha384-YxkBSwRUVkjExFck+8YKhyptbuFhqHPTFZF7Jf0BQjtDoMb3ZOks4fKi9YOUsywx",
|
||||
"integrity": "sha384-K4uV9mi/tmckBtGQUsQd7IPBRy29oh0P82BAYEvo3ZoH3ptAvh8EPBdvTCRy9XI4",
|
||||
"elements": [
|
||||
"upg-neighbor"
|
||||
],
|
||||
|
|
@ -1074,7 +1074,7 @@
|
|||
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-YxkBSwRUVkjExFck+8YKhyptbuFhqHPTFZF7Jf0BQjtDoMb3ZOks4fKi9YOUsywx\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-K4uV9mi/tmckBtGQUsQd7IPBRy29oh0P82BAYEvo3ZoH3ptAvh8EPBdvTCRy9XI4\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1157,7 +1157,7 @@
|
|||
"bundle": "wall-of-people-groups",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"output": "dx-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||
"integrity": "sha384-twh7RQAmx4whtfCvH0GDzMbjyNGJ32i2oYL8wjtqgiDxQYdJldeX38XLQnCFUyfj",
|
||||
"integrity": "sha384-DBf5SfneU3H95020XprfF9yJvywsVsS9Xd1oNP3xT1frhsLKMqz2M2fN3dXMPiUc",
|
||||
"elements": [
|
||||
"wall-of-people-groups"
|
||||
],
|
||||
|
|
@ -1173,7 +1173,7 @@
|
|||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-twh7RQAmx4whtfCvH0GDzMbjyNGJ32i2oYL8wjtqgiDxQYdJldeX38XLQnCFUyfj\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-DBf5SfneU3H95020XprfF9yJvywsVsS9Xd1oNP3xT1frhsLKMqz2M2fN3dXMPiUc\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
},
|
||||
"bundle": "wall-of-people-groups"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
|
||||
<script src="../doxa-research-map.js" integrity="sha384-HpQG4Fj6sgsMhugde9/B30Hjs959RizGYvEXSHT1q2o1EtPIEInNUR26vTZgj9r0" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-research-map.js" integrity="sha384-R5R8R1AWeOs/yz/0q2U/ahJkgcbu+8//vyw3ZfvYVYFA3O1xiULksIhA7975VeOm" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
|
||||
<script src="../../doxa-research-map.js" integrity="sha384-HpQG4Fj6sgsMhugde9/B30Hjs959RizGYvEXSHT1q2o1EtPIEInNUR26vTZgj9r0" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-research-map.js" integrity="sha384-R5R8R1AWeOs/yz/0q2U/ahJkgcbu+8//vyw3ZfvYVYFA3O1xiULksIhA7975VeOm" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-/cCytJvG3SP1z+z9F5hNR1chtv7aXYojtDyNMmMMyLCMBk2WttzYZysKxrQ1gNOc" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-hwuO0pWvI3B6X2EmGD+DI/xt6pRktnmNw83Mg9jG9OKgMHv/TTRKdX2hjVmtD2Wp" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-/cCytJvG3SP1z+z9F5hNR1chtv7aXYojtDyNMmMMyLCMBk2WttzYZysKxrQ1gNOc" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-hwuO0pWvI3B6X2EmGD+DI/xt6pRktnmNw83Mg9jG9OKgMHv/TTRKdX2hjVmtD2Wp" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-/cCytJvG3SP1z+z9F5hNR1chtv7aXYojtDyNMmMMyLCMBk2WttzYZysKxrQ1gNOc" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-simple-map.js" integrity="sha384-hwuO0pWvI3B6X2EmGD+DI/xt6pRktnmNw83Mg9jG9OKgMHv/TTRKdX2hjVmtD2Wp" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-/cCytJvG3SP1z+z9F5hNR1chtv7aXYojtDyNMmMMyLCMBk2WttzYZysKxrQ1gNOc" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-simple-map.js" integrity="sha384-hwuO0pWvI3B6X2EmGD+DI/xt6pRktnmNw83Mg9jG9OKgMHv/TTRKdX2hjVmtD2Wp" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue