feat: map provider swap — mapbox|maplibre engine + hybrid geocoder + provider badge; staging title doxa-maps
This commit is contained in:
parent
0f38a5448c
commit
c7260faaa3
25 changed files with 589 additions and 307 deletions
|
|
@ -191,15 +191,23 @@
|
|||
// with the value at request time; the .env file is gitignored.
|
||||
window.DOXA_TOKEN = '%VITE_MAPBOX_TOKEN%';
|
||||
|
||||
// Map engine override for slots WITHOUT an explicit config.provider:
|
||||
// ?provider=maplibre flips the default engine for those slots. A slot that
|
||||
// sets config.provider always wins (per-map). Default stays 'mapbox'.
|
||||
window.MAP_PROVIDER = new URLSearchParams(location.search).get('provider') || undefined;
|
||||
|
||||
window.DOXA_SLOTS = [
|
||||
// ── research-map: full 4-tab build (Prayer / Engagement / Adoption / Language Families) ──
|
||||
{ profile: 'research-map', label: 'Research — pray-tools', gem: 'amethyst',
|
||||
config: { dataSource: 'pray-tools', instanceId: 'dev-research-1' } },
|
||||
// Slot 1 renders with the default engine (Mapbox, unless ?provider= flips it).
|
||||
{ profile: 'research-map', label: 'Research — Mapbox engine', gem: 'amethyst',
|
||||
config: { dataSource: 'pray-tools', instanceId: 'dev-research-1', provider: 'mapbox' } },
|
||||
|
||||
{ profile: 'research-map', label: 'Research — second instance', gem: 'sapphire',
|
||||
config: { dataSource: 'pray-tools', instanceId: 'dev-research-2' } },
|
||||
// Slot 2 is pinned to MapLibre so both engines render side-by-side on one page.
|
||||
{ profile: 'research-map', label: 'Research — MapLibre engine', gem: 'emerald',
|
||||
config: { dataSource: 'pray-tools', instanceId: 'dev-research-2', provider: 'maplibre' } },
|
||||
|
||||
{ profile: 'research-map', label: 'Research — third instance', gem: 'emerald',
|
||||
// Slot 3 follows the page default (?provider= or Mapbox).
|
||||
{ profile: 'research-map', label: 'Research — default engine', gem: 'sapphire',
|
||||
config: { dataSource: 'pray-tools', instanceId: 'dev-research-3' } }
|
||||
];
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, mark
|
|||
|
||||
// ─── Composables (framework) ─────────────────────────────────────────────────
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider, providerLabel } 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'
|
||||
|
|
@ -322,6 +323,11 @@ useShadowStyles(`
|
|||
failure reads as "slow" and the user just waits; this says the map is gone. */
|
||||
.rm-error { position:absolute;top:50%;left:12px;right:12px;z-index:7;transform:translateY(-50%);text-align:center;font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fecaca;background:rgba(15,18,22,.95);border:1px solid rgba(254,202,202,.28);border-radius:10px;padding:12px 14px;box-shadow:0 2px 10px rgba(0,0,0,.4); }
|
||||
.rm-badge { position:absolute;top:10px;right:10px;background:rgba(124,140,248,0.12);color:#7c8cf8;border:1px solid rgba(124,140,248,0.3);padding:3px 9px;border-radius:4px;font-size:11px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;pointer-events:none;z-index:50; }
|
||||
/* Engine badge — bottom-left, unobtrusive. Colour differs per engine so a
|
||||
side-by-side comparison is readable at a glance. */
|
||||
.rm-provider-badge { position:absolute;bottom:8px;left:8px;z-index:50;pointer-events:none;padding:2px 8px;border-radius:999px;font:600 10px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:.04em;color:#fff;background:rgba(30,41,59,0.72);border:1px solid rgba(255,255,255,0.18);backdrop-filter:blur(2px); }
|
||||
.rm-provider-badge[data-provider="mapbox"] { background:rgba(37,99,235,0.78);border-color:rgba(147,197,253,0.5); }
|
||||
.rm-provider-badge[data-provider="maplibre"] { background:rgba(5,150,105,0.80);border-color:rgba(110,231,183,0.5); }
|
||||
/* Mobile-only legend slot — desktop hides via @media */
|
||||
.rm-legend-mobile-slot { display:none; }
|
||||
@media(max-width:767px){
|
||||
|
|
@ -336,6 +342,9 @@ useShadowStyles(`
|
|||
// ─── Config from ProfileLoader (provide/inject — never direct imports) ──────
|
||||
const profileConfig = inject('profileConfig')
|
||||
const mapboxToken = inject('mapboxToken')
|
||||
// Map engine: profile-config.provider → window.MAP_PROVIDER → 'mapbox' (default).
|
||||
const mapProvider = resolveMapProvider(profileConfig)
|
||||
const mapProviderName = providerLabel(mapProvider)
|
||||
const dataSource = inject('dataSource')
|
||||
const colorSet = inject('colorSet')
|
||||
const injectedInstanceId = inject('instanceId', null)
|
||||
|
|
@ -516,6 +525,7 @@ const RESEARCH_MAX_ZOOM = 22
|
|||
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
|
||||
containerRef: mapContainer,
|
||||
accessToken: mapboxToken.value,
|
||||
provider: mapProvider, // 'mapbox' (default) | 'maplibre'
|
||||
style: profileConfig?.value?.style ?? mapTheme.bootStyle(), // persisted-theme boot (shared)
|
||||
center: profileConfig?.value?.center ?? mapDefaults.center ?? [20, 10],
|
||||
zoom: profileConfig?.value?.zoom ?? mapDefaults.zoom ?? 1.8,
|
||||
|
|
@ -2016,6 +2026,9 @@ onBeforeUnmount(() => {
|
|||
<div class="map-scroll-edge map-scroll-edge--left" />
|
||||
<div class="map-scroll-edge map-scroll-edge--right" />
|
||||
|
||||
<!-- Engine badge (bottom-left) — shows which GL engine rendered this map. -->
|
||||
<div class="rm-provider-badge" :data-provider="mapProvider">{{ mapProviderName }}</div>
|
||||
|
||||
<!-- "RESEARCH" corner badge removed per qa.md R5 status flag (2026-04-27).
|
||||
Style class .rm-badge kept in shadow CSS in case future tabs want it. -->
|
||||
|
||||
|
|
@ -2026,6 +2039,7 @@ onBeforeUnmount(() => {
|
|||
v-if="appReady && map"
|
||||
:map-instance="map"
|
||||
:access-token="mapboxToken"
|
||||
:provider="mapProvider"
|
||||
:data-source-id="dataSource"
|
||||
:is-dark="isDark"
|
||||
:placeholder="geocoderPlaceholder"
|
||||
|
|
|
|||
BIN
bun.lock
generated
BIN
bun.lock
generated
Binary file not shown.
|
|
@ -91,7 +91,7 @@ function assetsBaseFor(depth) {
|
|||
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
|
||||
// (TREE_NAME stays in URLs/paths where it is a folder name, not branding).
|
||||
const DISPLAY_TITLE = (typeof DEST_CONFIG.displayTitle === 'string' && DEST_CONFIG.displayTitle.trim())
|
||||
? DEST_CONFIG.displayTitle.trim() : 'Embeddable JAMstack Maps'
|
||||
? DEST_CONFIG.displayTitle.trim() : 'doxa-maps'
|
||||
// Base URL every intra-tree ref (manifest iframeUrl/scriptUrl) is built against.
|
||||
// With embedBaseUrl set (e.g. "https://doxa.life/js"), refs are ABSOLUTE — the
|
||||
// build-time override for known hosts / partner embeds. WITHOUT it, refs are
|
||||
|
|
@ -233,7 +233,7 @@ header.forge .crumb a { color: var(--cy); }
|
|||
const GEMS = ['#46d4ff', '#b9a6f5', '#5ee0a0', '#ffc24b', '#f472b6', '#f59e0b', '#34d399']
|
||||
const hexRgb = (h) => { const n = parseInt(h.slice(1), 16); return (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) }
|
||||
|
||||
// ── The "Embeddable JAMstack Maps" staging shell ────────────────────────────────────────────
|
||||
// ── The "doxa-maps" staging shell ────────────────────────────────────────────
|
||||
// ONE staging design, emitted at every tree level (root + each bundle). It is a
|
||||
// thin HTML shell that loads the shared, org-agnostic renderer (staging-forge.js,
|
||||
// inlined at build time below) with a `window.FORGE` config telling it which tree
|
||||
|
|
@ -253,7 +253,7 @@ function readForgeAsset(rel) {
|
|||
}
|
||||
|
||||
/**
|
||||
* The shared Embeddable JAMstack Maps staging page. Self-contained: CSS + renderer are
|
||||
* The shared doxa-maps staging page. Self-contained: CSS + renderer are
|
||||
* INLINED (no external fetch that could 404 on a static host), except the
|
||||
* manifest which the renderer fetches relative to the page.
|
||||
*
|
||||
|
|
@ -312,7 +312,7 @@ function forgeHtml(o) {
|
|||
|
||||
// ── Page templates ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Root staging page — the Embeddable JAMstack Maps showing ALL bundles (scope='root'). */
|
||||
/** Root staging page — the doxa-maps showing ALL bundles (scope='root'). */
|
||||
function rootHtml(bundles, manifest) {
|
||||
return forgeHtml({
|
||||
inlineManifest: manifest,
|
||||
|
|
@ -322,13 +322,13 @@ function rootHtml(bundles, manifest) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Bundle staging page — the Embeddable JAMstack Maps showing THIS bundle's profiles (scope='bundle'). */
|
||||
/** Bundle staging page — the doxa-maps showing THIS bundle's profiles (scope='bundle'). */
|
||||
function bundleHtml(b, index, manifest) {
|
||||
return forgeHtml({
|
||||
inlineManifest: manifest,
|
||||
scope: 'bundle',
|
||||
bundle: b.bundle,
|
||||
title: `${DISPLAY_TITLE} · ${b.bundle}`,
|
||||
title: `${DISPLAY_TITLE}`,
|
||||
subtitle: `${b.bundle} — one bundle, its profiles as paste-anywhere maps`,
|
||||
crumbHref: '../index.html',
|
||||
crumbLabel: 'all bundles',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// staging.js — doxa-maps "Embeddable JAMstack Maps" staging showcase (dev only, no build step).
|
||||
// staging.js — doxa-maps "doxa-maps" staging showcase (dev only, no build step).
|
||||
//
|
||||
// UX: the header showcases the single-build-process architecture (one
|
||||
// shared core → one build → JS bundles → element tags → maps). A horizontal-scroll strip lists
|
||||
|
|
|
|||
|
|
@ -67,17 +67,24 @@ import { useI18n } from 'vue-i18n'
|
|||
import { useDoxaSearch } from '../../composables/useDoxaSearch.js'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
// The Mapbox and MapLibre geocoders emit identical DOM under different class
|
||||
// prefixes (.mapboxgl-ctrl-geocoder* vs .maplibregl-ctrl-geocoder*). dualEngine
|
||||
// duplicates every rule for both so one skin covers whichever engine is active.
|
||||
function dualEngine(css) {
|
||||
return css + css.replace(/mapboxgl/g, 'maplibregl')
|
||||
}
|
||||
|
||||
// iOS Safari auto-zooms the page whenever a text input with computed
|
||||
// font-size < 16px receives focus, and the "Done" keyboard button re-fires
|
||||
// the same zoom on blur. We override the Mapbox geocoder input to 16px in
|
||||
// the shadow root so the auto-zoom never triggers. 16px is the documented
|
||||
// threshold; setting it explicitly here also stops the page from getting
|
||||
// stuck zoomed-in on devices that disallow pinch-zoom recovery.
|
||||
useShadowStyles(
|
||||
useShadowStyles(dualEngine(
|
||||
'.mapboxgl-ctrl-geocoder--input,'
|
||||
+ '.mapboxgl-ctrl-geocoder input[type="text"],'
|
||||
+ '.mapboxgl-ctrl-geocoder input[type="search"]'
|
||||
+ '{font-size:16px !important;line-height:1.3;}',
|
||||
+ '{font-size:16px !important;line-height:1.3;}'),
|
||||
'geocoder-ios-zoom-fix'
|
||||
)
|
||||
|
||||
|
|
@ -85,8 +92,8 @@ useShadowStyles(
|
|||
// slot but is NOT vertically centered with the clear (×) button, so it reads as a
|
||||
// visual glitch while a query is in flight. The local search is fast enough that
|
||||
// the spinner adds no value (coder: "the loading indicator needs to not show up").
|
||||
useShadowStyles(
|
||||
'.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}',
|
||||
useShadowStyles(dualEngine(
|
||||
'.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}'),
|
||||
'geocoder-hide-loading'
|
||||
)
|
||||
|
||||
|
|
@ -98,11 +105,11 @@ useShadowStyles(
|
|||
// search icon is re-centered for the shorter box (the clear/× button is already centered
|
||||
// at top:50% by each profile's base rule). Shared here so every profile inherits it —
|
||||
// this REPLACES the per-profile @media mobile-height hack (bug-searchbar-mobile-height).
|
||||
useShadowStyles(
|
||||
useShadowStyles(dualEngine(
|
||||
'.mapboxgl-ctrl-geocoder{height:35px!important;min-height:35px!important;box-sizing:border-box!important;}'
|
||||
+ '.mapboxgl-ctrl-geocoder--input{height:35px!important;min-height:35px!important;box-sizing:border-box!important;'
|
||||
+ 'padding-top:0!important;padding-bottom:0!important;line-height:35px!important;}'
|
||||
+ '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}',
|
||||
+ '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}'),
|
||||
'geocoder-height-35'
|
||||
)
|
||||
|
||||
|
|
@ -119,7 +126,7 @@ useShadowStyles(
|
|||
// layout); they must not re-declare colour, radius, shadow or focus ring.
|
||||
//
|
||||
// Palette is research-map's — the reference implementation.
|
||||
useShadowStyles(
|
||||
useShadowStyles(dualEngine(
|
||||
// FILL — the pill always fills whatever container it is placed in, and the
|
||||
// container is what a profile sizes. This is the shared search-bar CONTRACT, not
|
||||
// a per-map preference: every map wants the pill to fill its slot. Sizing the
|
||||
|
|
@ -161,7 +168,7 @@ useShadowStyles(
|
|||
+ 'color:rgba(243,243,241,0.65)!important;}'
|
||||
+ '.geocoder-dark .mapboxgl-ctrl-geocoder--icon,.dsm-dark .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--icon{'
|
||||
+ 'fill:rgba(243,243,241,0.75)!important;}'
|
||||
+ '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}',
|
||||
+ '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}'),
|
||||
'geocoder-skin'
|
||||
)
|
||||
|
||||
|
|
@ -174,6 +181,12 @@ const props = defineProps({
|
|||
// pass a live instance, so their behavior is unchanged.
|
||||
mapInstance: { type: Object, required: false, default: null },
|
||||
accessToken: { type: String, required: true },
|
||||
/**
|
||||
* Map engine: 'mapbox' (default — MapboxGeocoder) | 'maplibre'
|
||||
* (MaplibreGeocoder + a Mapbox REST forwardGeocode, so place search still
|
||||
* works on a MapLibre map). The local people-group geocoder is shared.
|
||||
*/
|
||||
provider: { type: String, default: 'mapbox' },
|
||||
isDark: { type: Boolean, default: false },
|
||||
/**
|
||||
* When true, render the geocoder input into THIS component's own container
|
||||
|
|
@ -351,8 +364,17 @@ const standaloneContainer = ref(null)
|
|||
// The geocoder stylesheet must live inside the shadow root; injecting it into
|
||||
// the document head has no effect because the widget renders inside a
|
||||
// ShadowRoot. We target the closest ShadowRoot of the map container.
|
||||
function injectGeocoderCSS(shadowRoot) {
|
||||
const HREF = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'
|
||||
const GEOCODER_CSS = {
|
||||
mapbox: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
|
||||
maplibre: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
|
||||
}
|
||||
// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via
|
||||
// index.html; MapLibre's is injected on demand so a profile can flip provider
|
||||
// with no host change.
|
||||
const MAPLIBRE_GEOCODER_JS = 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js'
|
||||
|
||||
function injectGeocoderCSS(shadowRoot, prov) {
|
||||
const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox
|
||||
if (shadowRoot.querySelector(`link[href="${HREF}"]`)) return // already injected
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
|
|
@ -360,9 +382,72 @@ function injectGeocoderCSS(shadowRoot) {
|
|||
shadowRoot.prepend(link)
|
||||
}
|
||||
|
||||
/** Load the MaplibreGeocoder UMD global on demand (idempotent). */
|
||||
async function ensureMaplibreGeocoder() {
|
||||
if (typeof window !== 'undefined' && window.MaplibreGeocoder) return
|
||||
if (typeof document === 'undefined') return
|
||||
const existing = document.querySelector(`script[src="${MAPLIBRE_GEOCODER_JS}"]`)
|
||||
if (existing) {
|
||||
if (window.MaplibreGeocoder) return
|
||||
await new Promise(resolve => {
|
||||
existing.addEventListener('load', resolve, { once: true })
|
||||
existing.addEventListener('error', resolve, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = MAPLIBRE_GEOCODER_JS
|
||||
s.async = false
|
||||
s.addEventListener('load', resolve, { once: true })
|
||||
s.addEventListener('error', () => reject(new Error('[GeocoderComponent] Failed to load MapLibre geocoder')), { once: true })
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a MaplibreGeocoder `geocoderApi.forwardGeocode` backed by the Mapbox
|
||||
* Geocoding REST API — so place search still works on a MapLibre map (hybrid:
|
||||
* MapLibre basemap + Mapbox geocoding). Returns Carmen GeoJSON, which the
|
||||
* geocoder consumes directly. Empty result (no token / network error) leaves the
|
||||
* local people-group search fully functional.
|
||||
*/
|
||||
function makeMapboxForwardGeocode(token, getLang) {
|
||||
return async (config) => {
|
||||
const empty = { features: [] }
|
||||
if (!token || !config?.query) return empty
|
||||
try {
|
||||
const lang = Array.isArray(config.language) ? config.language[0] : (config.language || getLang() || 'en')
|
||||
const params = new URLSearchParams({
|
||||
access_token: token,
|
||||
autocomplete: 'true',
|
||||
limit: String(config.limit || 5),
|
||||
})
|
||||
if (lang) params.set('language', lang)
|
||||
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(config.query)}.json?${params.toString()}`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return empty
|
||||
const geojson = await res.json()
|
||||
return (geojson && Array.isArray(geojson.features)) ? geojson : empty
|
||||
} catch (e) {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geocoder lifecycle ────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
if (typeof MapboxGeocoder === 'undefined') {
|
||||
onMounted(async () => {
|
||||
const isMaplibre = props.provider === 'maplibre'
|
||||
|
||||
// Ensure the right geocoder global is present. Mapbox's is preloaded via
|
||||
// index.html; MapLibre's is injected on demand.
|
||||
if (isMaplibre) {
|
||||
await ensureMaplibreGeocoder()
|
||||
if (typeof MaplibreGeocoder === 'undefined') {
|
||||
console.error('[GeocoderComponent] MaplibreGeocoder global unavailable.')
|
||||
return
|
||||
}
|
||||
} else if (typeof MapboxGeocoder === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +463,7 @@ onMounted(() => {
|
|||
? standaloneContainer.value?.getRootNode?.()
|
||||
: map.getContainer?.()?.getRootNode?.()
|
||||
if (shadowRoot instanceof ShadowRoot) {
|
||||
injectGeocoderCSS(shadowRoot)
|
||||
injectGeocoderCSS(shadowRoot, props.provider)
|
||||
}
|
||||
|
||||
// Create geocoder instance.
|
||||
|
|
@ -393,11 +478,11 @@ onMounted(() => {
|
|||
// category tag the `render` callback (renderSuggestion) prepends.
|
||||
//
|
||||
// `localGeocoderOnly` is intentionally LEFT OUT — we want BOTH sources
|
||||
// (Mapbox places AND DOXA records) visible in the suggestions list.
|
||||
// eslint-disable-next-line no-undef
|
||||
geocoder.value = new MapboxGeocoder({
|
||||
accessToken: props.accessToken,
|
||||
mapboxgl: mapboxgl, // CDN global — same reference used by the map instance
|
||||
// (place results AND DOXA records) visible in the suggestions list.
|
||||
//
|
||||
// Shared options — the two geocoders share almost the entire option surface;
|
||||
// only the constructor shape + the geocoding backend differ (see below).
|
||||
const sharedOpts = {
|
||||
marker: false,
|
||||
// Standalone (header) bars are search-only: results drive the selection bus,
|
||||
// not a map flyTo — so disable flyTo (no map). Map-control mode keeps the
|
||||
|
|
@ -409,24 +494,42 @@ onMounted(() => {
|
|||
// mode keeps the default (byte-identical: !standalone === true).
|
||||
trackProximity: !props.standalone,
|
||||
placeholder: effectivePlaceholder.value,
|
||||
// Localize Mapbox's remote place results AND the geocoder's own UI strings.
|
||||
// Mapbox accepts ISO-639-1 codes (comma-separated); the active i18n locale
|
||||
// is set once at mount from profile-config.lang (default 'en'). See loc-002.
|
||||
// Localize remote place results AND the geocoder's own UI strings. Mapbox
|
||||
// accepts ISO-639-1 codes; the active i18n locale is set once at mount from
|
||||
// profile-config.lang (default 'en'). See loc-002.
|
||||
language: locale.value || 'en',
|
||||
localGeocoder: doxaLocalGeocoder,
|
||||
// Raise suggestion limit well above Mapbox's default (5) so a query like
|
||||
// "India" can surface many people-groups. CSS caps the dropdown height
|
||||
// and adds overflow-y:auto so the user can scroll through them.
|
||||
// Raise suggestion limit well above the default (5) so a query like "India"
|
||||
// can surface many people-groups. CSS caps the dropdown height + overflow.
|
||||
limit: 20,
|
||||
// Ensure our local results show even when Mapbox has remote matches.
|
||||
// (Some versions default to localGeocoder results appearing AFTER the
|
||||
// remote ones; keeping default order is fine — the category tag makes
|
||||
// our rows unambiguous.)
|
||||
zoom: 6,
|
||||
// Custom row renderer — injects structured, bold-labeled metadata
|
||||
// for DOXA results. See renderSuggestion() above.
|
||||
render: renderSuggestion
|
||||
})
|
||||
// Custom row renderer — injects structured, bold-labeled metadata for DOXA
|
||||
// results. See renderSuggestion() above.
|
||||
render: renderSuggestion,
|
||||
}
|
||||
|
||||
if (isMaplibre) {
|
||||
// MapLibre map + Mapbox geocoding (hybrid). forwardGeocode hits the Mapbox
|
||||
// REST API with the same public token; the local people-group search is
|
||||
// unchanged. Requires the geocoderApi as the FIRST constructor arg.
|
||||
// eslint-disable-next-line no-undef
|
||||
const gl = typeof maplibregl !== 'undefined' ? maplibregl : undefined
|
||||
const geocoderApi = { forwardGeocode: makeMapboxForwardGeocode(props.accessToken, () => locale.value) }
|
||||
// eslint-disable-next-line no-undef
|
||||
geocoder.value = new MaplibreGeocoder(geocoderApi, {
|
||||
...sharedOpts,
|
||||
maplibregl: gl,
|
||||
showResultsWhileTyping: true,
|
||||
reverseGeocode: false,
|
||||
})
|
||||
} else {
|
||||
// eslint-disable-next-line no-undef
|
||||
geocoder.value = new MapboxGeocoder({
|
||||
accessToken: props.accessToken,
|
||||
mapboxgl: mapboxgl, // CDN global — same reference used by the map instance
|
||||
...sharedOpts,
|
||||
})
|
||||
}
|
||||
|
||||
// Forward geocoder events as Vue emits. When a DOXA people-group result
|
||||
// is chosen we additionally emit `people-group-result` carrying the
|
||||
|
|
|
|||
|
|
@ -28,24 +28,87 @@
|
|||
import { ref } from 'vue'
|
||||
import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
|
||||
|
||||
const MAPBOX_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css'
|
||||
// ─── Engine registry (provider → CDN script + css + global) ──────────────────
|
||||
// The map engine is loaded as a page global (CDN <script>). Profiles / index.html
|
||||
// may preload it; if absent we inject it here. Mapbox is the default — its URLs
|
||||
// match the existing <script> tags so nothing double-loads or changes.
|
||||
const ENGINES = {
|
||||
mapbox: {
|
||||
global: 'mapboxgl',
|
||||
js: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js',
|
||||
css: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css',
|
||||
},
|
||||
maplibre: {
|
||||
global: 'maplibregl',
|
||||
js: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js',
|
||||
css: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css',
|
||||
},
|
||||
}
|
||||
|
||||
const MAPBOX_CSS_URL = ENGINES.mapbox.css
|
||||
// Free basemap for MapLibre (no token). Deliberately NOT the hand-tuned Mapbox
|
||||
// style — see the provider notes; a MapLibre map looks different by design.
|
||||
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
|
||||
|
||||
/** The live engine global for a provider, or undefined if not yet loaded. */
|
||||
function engineGlobal(provider) {
|
||||
const name = ENGINES[provider]?.global || ENGINES.mapbox.global
|
||||
return typeof window !== 'undefined' ? window[name] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the Mapbox GL CSS into the shadow root that contains the given
|
||||
* Ensures the engine's CDN <script> (+ head css) is loaded for the provider.
|
||||
* Idempotent — resolves immediately if the global already exists (preloaded via
|
||||
* index.html), otherwise injects the script and awaits its load event.
|
||||
*/
|
||||
async function ensureEngineScript(provider) {
|
||||
const cfg = ENGINES[provider] || ENGINES.mapbox
|
||||
if (engineGlobal(provider)) return // already loaded — no-op
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
if (!document.querySelector(`link[href="${cfg.css}"]`)) {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = cfg.css
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
const existing = document.querySelector(`script[src="${cfg.js}"]`)
|
||||
if (existing) {
|
||||
if (engineGlobal(provider)) return
|
||||
await new Promise(resolve => {
|
||||
existing.addEventListener('load', resolve, { once: true })
|
||||
existing.addEventListener('error', resolve, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = cfg.js
|
||||
s.async = false
|
||||
s.addEventListener('load', resolve, { once: true })
|
||||
s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true })
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the engine's GL CSS into the shadow root that contains the given
|
||||
* element, then waits for it to finish loading.
|
||||
*
|
||||
* - Skips if the element is in the regular document (no shadow root).
|
||||
* - Skips if the CSS link is already present and loaded (idempotent).
|
||||
* - Always resolves — never hangs (2 s timeout fallback).
|
||||
*/
|
||||
async function ensureMapboxCSS(element) {
|
||||
async function ensureEngineCSS(element, cssUrl) {
|
||||
const root = element.getRootNode()
|
||||
if (!(root instanceof ShadowRoot)) return // light DOM — wrapper <link> is enough
|
||||
|
||||
const timeout = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// Already present — check if loaded via `sheet` or wait for event
|
||||
const existing = root.querySelector(`link[href="${MAPBOX_CSS_URL}"]`)
|
||||
const existing = root.querySelector(`link[href="${cssUrl}"]`)
|
||||
if (existing) {
|
||||
// `sheet` is set synchronously once the stylesheet is parsed — reliable check
|
||||
if (existing.sheet) return
|
||||
|
|
@ -65,7 +128,7 @@ async function ensureMapboxCSS(element) {
|
|||
// so we cannot miss the load event even if the browser resolves it synchronously
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = MAPBOX_CSS_URL
|
||||
link.href = cssUrl
|
||||
|
||||
await Promise.race([
|
||||
new Promise(resolve => {
|
||||
|
|
@ -77,6 +140,15 @@ async function ensureMapboxCSS(element) {
|
|||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective style for the MapLibre engine. A mapbox:// style needs a token and
|
||||
* the Mapbox engine, so it is ignored here — an explicit http(s) style URL is
|
||||
* honored, otherwise the free OpenFreeMap basemap is used.
|
||||
*/
|
||||
function maplibreStyle(style) {
|
||||
return (typeof style === 'string' && /^https?:\/\//.test(style)) ? style : OPENFREEMAP_STYLE
|
||||
}
|
||||
|
||||
// ─── Aspect-aware zoom-out floor (Feedback #2+#4 REDO) ───────────────────────
|
||||
// We KEEP renderWorldCopies:true (infinite horizontal scroll) and instead cap
|
||||
// zoom-out with a DYNAMIC minZoom floor computed from the viewport. The floor
|
||||
|
|
@ -127,6 +199,8 @@ export function useMapInstance({
|
|||
containerRef,
|
||||
accessToken,
|
||||
style,
|
||||
// provider: 'mapbox' (default — unchanged) | 'maplibre'. Selects the GL engine.
|
||||
provider = 'mapbox',
|
||||
center = [20, 10],
|
||||
zoom = 2,
|
||||
// minZoom: a profile may PIN a value (e.g. 0.5). It is treated as a LOWER
|
||||
|
|
@ -159,29 +233,38 @@ export function useMapInstance({
|
|||
// Holds the gray side-edge touch handlers so destroy() can remove them.
|
||||
let _detachEdgeBorders = null
|
||||
|
||||
const isMaplibre = provider === 'maplibre'
|
||||
|
||||
async function initializeMap() {
|
||||
if (typeof mapboxgl === 'undefined') {
|
||||
console.error('mapboxgl is not available. Add the Mapbox GL JS script to your page.')
|
||||
return
|
||||
}
|
||||
if (!containerRef.value) {
|
||||
console.error('containerRef.value is null — call initializeMap() inside onMounted()')
|
||||
return
|
||||
}
|
||||
|
||||
// ─── API Key validation ──────────────────────────────────────────
|
||||
if (!accessToken) {
|
||||
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON.')
|
||||
return
|
||||
}
|
||||
if (!accessToken.startsWith('pk.')) {
|
||||
console.error('Mapbox token looks invalid (expected "pk." prefix). Got:', accessToken.slice(0, 10) + '...')
|
||||
// Load the selected engine (no-op if index.html preloaded it).
|
||||
await ensureEngineScript(provider)
|
||||
const gl = engineGlobal(provider)
|
||||
if (!gl) {
|
||||
console.error(`[useMapInstance] ${isMaplibre ? 'maplibregl' : 'mapboxgl'} is not available. `
|
||||
+ `Add the ${isMaplibre ? 'MapLibre' : 'Mapbox'} GL JS script to your page.`)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for Mapbox CSS inside the shadow root before creating the Map.
|
||||
// ─── API Key validation (Mapbox only — MapLibre + OpenFreeMap need no token) ──
|
||||
if (!isMaplibre) {
|
||||
if (!accessToken) {
|
||||
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON.')
|
||||
return
|
||||
}
|
||||
if (!accessToken.startsWith('pk.')) {
|
||||
console.error('Mapbox token looks invalid (expected "pk." prefix). Got:', accessToken.slice(0, 10) + '...')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the engine CSS inside the shadow root before creating the Map.
|
||||
// Without this, _detectMissingCSS fires and the canvas initializes at 0px.
|
||||
await ensureMapboxCSS(containerRef.value)
|
||||
await ensureEngineCSS(containerRef.value, isMaplibre ? ENGINES.maplibre.css : MAPBOX_CSS_URL)
|
||||
|
||||
// Wait one animation frame so the browser has laid out the container.
|
||||
// Mapbox reads clientWidth/clientHeight at init — if layout hasn't settled
|
||||
|
|
@ -207,7 +290,7 @@ export function useMapInstance({
|
|||
// See intel/discovery-reports/W1A-mapbox-create-web-app-bp.md for rationale.
|
||||
// Rename deferred: MFEs reference the current name; needs a coordinated update.
|
||||
const token = accessToken
|
||||
mapboxgl.accessToken = token
|
||||
if (!isMaplibre) gl.accessToken = token
|
||||
|
||||
// ─── Effective zoom-out floor ────────────────────────────────────
|
||||
// floor = the aspect-aware no-duplicate-world minZoom. A profile-pinned
|
||||
|
|
@ -228,31 +311,51 @@ export function useMapInstance({
|
|||
}
|
||||
const initialMinZoom = floorFor(el.clientWidth, el.clientHeight)
|
||||
|
||||
map.value = new mapboxgl.Map({
|
||||
accessToken: token, // BP #1 from W1A — per-instance token; avoids last-writer-wins on multi-embed pages
|
||||
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
|
||||
style,
|
||||
center,
|
||||
zoom,
|
||||
minZoom: initialMinZoom,
|
||||
maxZoom,
|
||||
pitch,
|
||||
bearing,
|
||||
dragRotate: false, // disorienting north-drift: no right-click+drag bearing change (desktop)
|
||||
touchPitch: false, // no two-finger pitch on touch devices
|
||||
renderWorldCopies, // true → infinite worlds; zoom-out capped by the dynamic floor
|
||||
// NOTE: cooperativeGestures (the two-finger-to-zoom requirement) was REMOVED
|
||||
// per coder directive 2026-06-24 — one-finger interaction must work normally.
|
||||
// The single-finger page-scroll escape is now the gray side-edge affordance
|
||||
// wired below (showEdgeBorders): the .map-scroll-edge gutters flash gray while
|
||||
// touching the map and are scrollable to move the page past the map.
|
||||
...(maxBounds ? { maxBounds } : {}), // only if a profile explicitly supplied bounds
|
||||
attributionControl: false
|
||||
})
|
||||
if (isMaplibre) {
|
||||
// MapLibre branch — free OpenFreeMap basemap, NO access token. Shares the
|
||||
// GL-JS Map option surface; only the token/style differ from Mapbox.
|
||||
map.value = new gl.Map({
|
||||
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
|
||||
style: maplibreStyle(style),
|
||||
center,
|
||||
zoom,
|
||||
minZoom: initialMinZoom,
|
||||
maxZoom,
|
||||
pitch,
|
||||
bearing,
|
||||
dragRotate: false,
|
||||
renderWorldCopies,
|
||||
...(maxBounds ? { maxBounds } : {}),
|
||||
attributionControl: false
|
||||
})
|
||||
} else {
|
||||
map.value = new gl.Map({
|
||||
accessToken: token, // BP #1 from W1A — per-instance token; avoids last-writer-wins on multi-embed pages
|
||||
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
|
||||
style,
|
||||
center,
|
||||
zoom,
|
||||
minZoom: initialMinZoom,
|
||||
maxZoom,
|
||||
pitch,
|
||||
bearing,
|
||||
dragRotate: false, // disorienting north-drift: no right-click+drag bearing change (desktop)
|
||||
touchPitch: false, // no two-finger pitch on touch devices
|
||||
renderWorldCopies, // true → infinite worlds; zoom-out capped by the dynamic floor
|
||||
// NOTE: cooperativeGestures (the two-finger-to-zoom requirement) was REMOVED
|
||||
// per coder directive 2026-06-24 — one-finger interaction must work normally.
|
||||
// The single-finger page-scroll escape is now the gray side-edge affordance
|
||||
// wired below (showEdgeBorders): the .map-scroll-edge gutters flash gray while
|
||||
// touching the map and are scrollable to move the page past the map.
|
||||
...(maxBounds ? { maxBounds } : {}), // only if a profile explicitly supplied bounds
|
||||
attributionControl: false
|
||||
})
|
||||
}
|
||||
|
||||
// Belt-and-suspenders to dragRotate:false — kills the two-finger twist→rotate
|
||||
// handler so the north bearing can never drift on any input method.
|
||||
map.value.touchZoomRotate.disableRotation()
|
||||
// handler so the north bearing can never drift on any input method. Present on
|
||||
// both engines.
|
||||
try { map.value.touchZoomRotate.disableRotation() } catch (_) { /* engine may differ */ }
|
||||
|
||||
// ─── Edge scroll-affordance strips (custom page-scroll "scrollbar") ─────────
|
||||
// The .map-scroll-edge strips (left + right, every profile: pointer-events auto,
|
||||
|
|
|
|||
47
library/composables/useMapProvider.js
Normal file
47
library/composables/useMapProvider.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* useMapProvider.js — resolve the active map engine ("provider").
|
||||
*
|
||||
* Resolution order (first defined wins):
|
||||
* 1. profile-config `provider` — per-map, set in the profile-config JSON
|
||||
* 2. window.MAP_PROVIDER — runtime override (e.g. a host reads ?provider=…)
|
||||
* 3. 'mapbox' — default; existing behaviour, never regresses
|
||||
*
|
||||
* Valid values: 'mapbox' | 'maplibre'. Anything else falls back to the default.
|
||||
*/
|
||||
|
||||
import { inject } from 'vue'
|
||||
|
||||
export const MAP_PROVIDERS = ['mapbox', 'maplibre']
|
||||
export const DEFAULT_PROVIDER = 'mapbox'
|
||||
|
||||
/** Unwrap a ref/computed to its config object; pass plain objects through. */
|
||||
function unwrapConfig(profileConfig) {
|
||||
if (profileConfig && typeof profileConfig === 'object'
|
||||
&& 'value' in profileConfig && !('provider' in profileConfig) && !('profile' in profileConfig)) {
|
||||
return profileConfig.value
|
||||
}
|
||||
return profileConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider from a profile-config (object or ref) + window fallback.
|
||||
* Pure — safe to call outside a Vue setup().
|
||||
*/
|
||||
export function resolveMapProvider(profileConfig) {
|
||||
const cfg = unwrapConfig(profileConfig)
|
||||
const fromConfig = cfg?.provider
|
||||
const fromWindow = typeof window !== 'undefined' ? window.MAP_PROVIDER : undefined
|
||||
const raw = String(fromConfig || fromWindow || DEFAULT_PROVIDER).toLowerCase().trim()
|
||||
return MAP_PROVIDERS.includes(raw) ? raw : DEFAULT_PROVIDER
|
||||
}
|
||||
|
||||
/** Human-readable engine label for a badge / UI. */
|
||||
export function providerLabel(provider) {
|
||||
return provider === 'maplibre' ? 'MapLibre' : 'Mapbox'
|
||||
}
|
||||
|
||||
/** Convenience for use inside a Vue setup() — injects the provided profileConfig. */
|
||||
export function useMapProvider() {
|
||||
const profileConfig = inject('profileConfig', null)
|
||||
return resolveMapProvider(profileConfig)
|
||||
}
|
||||
|
|
@ -45,7 +45,9 @@
|
|||
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-geocoder": "^1.7.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"maplibre-gl": "^5.6.0",
|
||||
"pinia": "^2.3.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"vue": "^3.5.20",
|
||||
|
|
|
|||
6
public/doxa-maps/cities-internet/index.html
generated
6
public/doxa-maps/cities-internet/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · cities-internet</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · cities-internet<span class="ac"> / cities-internet</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / cities-internet</span></h1>
|
||||
<div class="sub">cities-internet — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
108
public/doxa-maps/country-posters/country-posters.js
generated
108
public/doxa-maps/country-posters/country-posters.js
generated
File diff suppressed because one or more lines are too long
6
public/doxa-maps/country-posters/index.html
generated
6
public/doxa-maps/country-posters/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · country-posters</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · country-posters<span class="ac"> / country-posters</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / country-posters</span></h1>
|
||||
<div class="sub">country-posters — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
48
public/doxa-maps/demo-map/demo-map.js
generated
48
public/doxa-maps/demo-map/demo-map.js
generated
File diff suppressed because one or more lines are too long
6
public/doxa-maps/demo-map/index.html
generated
6
public/doxa-maps/demo-map/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · demo-map</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · demo-map<span class="ac"> / demo-map</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / demo-map</span></h1>
|
||||
<div class="sub">demo-map — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
113
public/doxa-maps/doxa-research-map/doxa-research-map.js
generated
113
public/doxa-maps/doxa-research-map/doxa-research-map.js
generated
File diff suppressed because one or more lines are too long
6
public/doxa-maps/doxa-research-map/index.html
generated
6
public/doxa-maps/doxa-research-map/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · doxa-research-map</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · doxa-research-map<span class="ac"> / doxa-research-map</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / doxa-research-map</span></h1>
|
||||
<div class="sub">doxa-research-map — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
70
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
70
public/doxa-maps/doxa-simple-map/doxa-simple-map.js
generated
File diff suppressed because one or more lines are too long
6
public/doxa-maps/doxa-simple-map/index.html
generated
6
public/doxa-maps/doxa-simple-map/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · doxa-simple-map</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · doxa-simple-map<span class="ac"> / doxa-simple-map</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / doxa-simple-map</span></h1>
|
||||
<div class="sub">doxa-simple-map — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
6
public/doxa-maps/gospel-resources/index.html
generated
6
public/doxa-maps/gospel-resources/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · gospel-resources</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · gospel-resources<span class="ac"> / gospel-resources</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / gospel-resources</span></h1>
|
||||
<div class="sub">gospel-resources — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
6
public/doxa-maps/index.html
generated
6
public/doxa-maps/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · staging</title>
|
||||
<title>doxa-maps · staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps<span class="ac"></span></h1>
|
||||
<h1>doxa-maps<span class="ac"></span></h1>
|
||||
<div class="sub">one shared core → many self-contained, paste-anywhere map bundles</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
6
public/doxa-maps/key-cities/index.html
generated
6
public/doxa-maps/key-cities/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · key-cities</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · key-cities<span class="ac"> / key-cities</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / key-cities</span></h1>
|
||||
<div class="sub">key-cities — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
6
public/doxa-maps/my-upg-100-list/index.html
generated
6
public/doxa-maps/my-upg-100-list/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · my-upg-100-list</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · my-upg-100-list<span class="ac"> / my-upg-100-list</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / my-upg-100-list</span></h1>
|
||||
<div class="sub">my-upg-100-list — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
46
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
46
public/doxa-maps/my-upg-100-list/my-upg-100-list.js
generated
File diff suppressed because one or more lines are too long
6
public/doxa-maps/template-bundle/index.html
generated
6
public/doxa-maps/template-bundle/index.html
generated
|
|
@ -3,12 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable JAMstack Maps · template-bundle</title>
|
||||
<title>doxa-maps</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
|
||||
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
|
||||
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
|
||||
architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
|
||||
architectural dark, zero folder-hopping. doxa-maps design language. */
|
||||
:root {
|
||||
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
|
||||
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
|
||||
|
|
@ -117,7 +117,7 @@ header.top .sub { margin-top: 3px; font: 12px/1.4 var(--sans); color: var(--mut)
|
|||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>Embeddable JAMstack Maps · template-bundle<span class="ac"> / template-bundle</span></h1>
|
||||
<h1>doxa-maps<span class="ac"> / template-bundle</span></h1>
|
||||
<div class="sub">template-bundle — one bundle, its profiles as paste-anywhere maps</div>
|
||||
</header>
|
||||
<div class="strip" id="frameInfo">loading…</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue