Neither element had a :host rule at all, so each fell back to the custom-element
default — display:inline, no height of its own — while its root div carried
position:absolute;inset:0. Absolute positioning resolves against the nearest
POSITIONED ancestor, so on a partner page both resolved against the PARTNER's
layout rather than against this element.
rega measured it on a partner page with a 420px host box: connectivity-list
painted to y=16,799 and data-tables to y=1,698 — 16,355px and 1,254px below the
box, over whatever the partner had underneath.
The same fix as connectivity-map's (60cb4ff18), for the same reason:
:host{display:block;position:relative;width:100%;height:100%;min-height:320px}
display:block gives the element a box; position:relative makes that box the
containing block; height:100% lets a definite-height slot decide; min-height is
the floor so an auto-height host grows to fit instead of collapsing.
NO NEW SCROLL CONTAINER WAS NEEDED. .ci and .ct already carry overflow-y:auto —
the scroller existed, it simply had no host to be bounded by. Once contained,
the list and the table scroll inside the box, which is what an embed should do
in a definite-height slot.
min-height is 320px here, not the map's 420px: regions-list.vue:408 is the house
pattern for list profiles, and a list is legible shorter than a map is.
(cherry picked from commit 08ad95fa86e64dfe043befb156e358c91319e511)
139 lines
7.5 KiB
Vue
139 lines
7.5 KiB
Vue
<script setup>
|
|
/**
|
|
* data-tables.vue — the CITIES DATA list view: every research table, one tab each.
|
|
*
|
|
* @description Four validated research tables, one tab each, with sortable and filterable columns, CSV and JSON
|
|
* export, and each table's caveats rendered in the UI rather than hidden in a footnote.
|
|
*
|
|
* Four validated Tabulator tables from the flight's research corpora, each with
|
|
* sortable/filterable columns, CSV+JSON export, and its caveats rendered IN the UI.
|
|
*
|
|
* > design note 2026-08-01: "all the tabulator data and trees for all the multiple
|
|
* > various tabulator tables for all this data all organized in list format with
|
|
* > full granularity."
|
|
*
|
|
* The tables are produced by library/scripts/research/build-tabulator-tables.mjs
|
|
* (the M+V of IEMV) — flat scalar rows, one row per fact, validated before write.
|
|
*
|
|
* WHY THE CAVEATS RENDER: a list view strips context. Someone reading
|
|
* "Internet 12.6%" needs to know it is a NATIONAL figure, and someone reading a
|
|
* network operator needs to know an ASN holder set includes banks as well as ISPs.
|
|
* DataTable surfaces any meta key describing a limit, so the qualification travels
|
|
* with the data instead of living in a file nobody opens.
|
|
*/
|
|
import { computed, inject, ref, watch } from 'vue'
|
|
import DataTable from '@map/components/tables/DataTable.vue'
|
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
|
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
|
import { useOptionalT, fillParams } from '@map/composables/useOptionalT.js'
|
|
import { tStatic } from '@map/i18n/index.js'
|
|
|
|
|
|
const profileConfig = inject('profileConfig', ref(null))
|
|
const isDark = computed(() => profileConfig.value?.isDark === true)
|
|
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
|
|
|
|
// ── UI text: library/i18n/locales/<lc>/connectivity.json (the connectivity-map pattern) ──────
|
|
// i18n is OPTIONAL: useI18n() throws on a host that never installed it, so English from the
|
|
// shipped catalog is the fallback, never a blank or a key. Region and country names are data.
|
|
// C4 wave 3b: the guard is library/composables/useOptionalT.js now, and the fallback resolves through
|
|
// tStatic — the RUNTIME LOCALE first, English as the last resort. exp-shepherd's call as this
|
|
// plugin's owner (2026-09-26), on the same reasoning gospel-plugin gave: the bundle already carries
|
|
// all 11 catalogues, so a partner embedding without vue-i18n being served English is a bug, not a
|
|
// decision. The old enLookup was English-only, which is exactly what that served.
|
|
// The local fill() is gone with the guard: fillParams applies the same interpolation, except it leaves
|
|
// a placeholder for a NULL value where this fill() rendered the string "null".
|
|
const t = useOptionalT('connectivity')
|
|
// NUMERALS ARE HELD fleet-wide (2026-09-26): a locale-following format would switch
|
|
// 'ar' to Arabic-Indic digits. Until that is decided, numbers keep the browser-default format they
|
|
// had before this pass; only the TEXT is translated.
|
|
const num = (n) => (Number(n) || 0).toLocaleString()
|
|
|
|
const TABS = [
|
|
{ id: 'cities', file: 'key-cities' },
|
|
{ id: 'internet', file: 'country-connectivity' },
|
|
{ id: 'operators', file: 'network-operators' },
|
|
{ id: 'social', file: 'social-platforms' },
|
|
// Messaging apps are the channel referral stats CANNOT see — WhatsApp tops 54
|
|
// countries, Telegram 38, and neither appears in StatCounter at all.
|
|
{ id: 'messaging', file: 'messaging-channels' },
|
|
]
|
|
const active = ref('cities')
|
|
const current = computed(() => TABS.find((t) => t.id === active.value) || TABS[0])
|
|
|
|
// TABLES ARE FETCHED AT RUNTIME, NOT IMPORTED. Static imports inlined 5.1MB of
|
|
// JSON into these bundles (2.0MB and 2.9MB) because the build is a single-file
|
|
// IIFE with inlineDynamicImports:true — every view then waits on one parse. Same
|
|
// trap that took the 100-list to 6MB. Fetching per tab keeps the bundle small AND
|
|
// means a user who never opens 'Social Platforms' never downloads it.
|
|
const cache = ref({})
|
|
const loading = ref(false)
|
|
const loadErr = ref(null)
|
|
async function ensure(file) {
|
|
if (cache.value[file]) return
|
|
loading.value = true; loadErr.value = null
|
|
try {
|
|
const r = await fetch(`${assetsBase.value}/tables/${file}.json`)
|
|
if (!r.ok) throw new Error(String(r.status))
|
|
cache.value = { ...cache.value, [file]: await r.json() }
|
|
} catch (_) {
|
|
// The reader sees the shared, translated "Could not load {name}." (qualified via tStatic: this file's
|
|
// t() would prefix connectivity.), naming the tab in their language; the status went to the console.
|
|
const tab = TABS.find((x) => x.file === file)
|
|
loadErr.value = fillParams(tStatic('messages.couldNotLoadNamed'), { name: tab ? t('tables.tabs.' + tab.id) : file })
|
|
}
|
|
finally { loading.value = false }
|
|
}
|
|
watch(current, (t) => t && ensure(t.file), { immediate: true })
|
|
const table = computed(() => cache.value[current.value?.file] || null)
|
|
|
|
useShadowStyles(
|
|
// ★ THE TABLE MUST STAY INSIDE ITS HOST BOX — the same defect and the same fix as connectivity-list.
|
|
// With no :host rule the element is inline with no height, and .ct (position:absolute;inset:0)
|
|
// resolved against the partner page's positioned ancestor instead of this element. rega measured
|
|
// content painting to y=1,698, which is 1,254px BELOW a 420px host box.
|
|
// .ct already has overflow-y:auto, so containing it is enough to make the table scroll in the box.
|
|
':host{display:block;position:relative;width:100%;height:100%;min-height:320px}' +
|
|
'.ct{position:absolute;inset:0;overflow-y:auto;padding:18px;background:#f6f7f9;color:#0f1419;' +
|
|
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" +
|
|
'.ct.is-dark{background:#0a1410;color:#e5e7eb}' +
|
|
'.ct-h{margin:0 0 4px;font-size:18px;font-weight:700}' +
|
|
'.ct-sub{margin:0 0 14px;font-size:12.5px;opacity:.72;line-height:1.5}' +
|
|
'.ct-tabs{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:14px}' +
|
|
'.ct-tab{font:600 12px inherit;padding:7px 14px;border-radius:999px;cursor:pointer;' +
|
|
'border:1px solid rgba(0,0,0,.14);background:transparent;color:inherit}' +
|
|
'.ct-tab.on{background:#10b981;border-color:#10b981;color:#04241a}' +
|
|
'.ct-load{padding:22px;text-align:center;font-size:12.5px;opacity:.6}' +
|
|
'.ct-err{padding:11px 13px;border-radius:8px;background:rgba(220,38,38,.10);color:#a12020;font-size:12.5px}' +
|
|
'.ct-n{font-size:10.5px;opacity:.6;margin-left:5px;font-variant-numeric:tabular-nums}',
|
|
'doxa-cities-tables',
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="ct" :class="{ 'is-dark': isDark }">
|
|
<h2 class="ct-h">{{ t('connectivity.tables.title') }}</h2>
|
|
<p class="ct-sub">{{ t('connectivity.tables.intro') }}</p>
|
|
|
|
<div class="ct-tabs">
|
|
<button v-for="tab in TABS" :key="tab.id" type="button"
|
|
class="ct-tab" :class="{ on: active === tab.id }" @click="active = tab.id">
|
|
{{ t('connectivity.tables.tabs.' + tab.id) }}<span class="ct-n" v-if="cache[tab.file]">{{ num((cache[tab.file].rows || []).length) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- :key forces a fresh Tabulator per tab rather than mutating one in place;
|
|
swapping both columns AND data on a live instance is where column state
|
|
(frozen, widths, filters) desynchronises from the new schema. -->
|
|
<div v-if="loadErr" class="ct-err">{{ loadErr }}</div>
|
|
<div v-else-if="!table" class="ct-load">{{ t('connectivity.tables.loading', { name: t('connectivity.tables.tabs.' + current.id) }) }}</div>
|
|
<DataTable
|
|
v-else
|
|
:key="current.id"
|
|
:table="table"
|
|
:assets-base="assetsBase"
|
|
:is-dark="isDark"
|
|
height="600px"
|
|
/>
|
|
</div>
|
|
</template>
|