borderless / assets /globe.js
spagestic's picture
Richer Globe Research Popups is implemented
a5da148
Raw
History Blame Contribute Delete
19.4 kB
/* assets/globe.js */
import { renderMarkdownHtml } from "/assets/markdown.js?v=1";
const MAPLIBRE_VERSION = "5.24.0";
const MAPLIBRE_BASE = `https://unpkg.com/maplibre-gl@${MAPLIBRE_VERSION}/dist`;
function ensureMapLibre() {
if (typeof maplibregl !== "undefined") {
return Promise.resolve();
}
if (window.__maplibreLoadPromise) {
return window.__maplibreLoadPromise;
}
window.__maplibreLoadPromise = new Promise((resolve, reject) => {
if (!document.querySelector('link[data-globe-maplibre-css="1"]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `${MAPLIBRE_BASE}/maplibre-gl.css`;
link.dataset.globeMaplibreCss = "1";
document.head.appendChild(link);
}
const script = document.createElement("script");
script.src = `${MAPLIBRE_BASE}/maplibre-gl.js`;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load MapLibre GL"));
document.head.appendChild(script);
});
return window.__maplibreLoadPromise;
}
const GLOBE_DEFAULTS = {
style: "https://tiles.openfreemap.org/styles/liberty",
center: [0, 20],
zoom: 1.5,
minZoom: 0,
maxZoom: 22,
bearing: 0,
pitch: 0,
minPitch: 0,
maxPitch: 85,
projection: "globe",
attributionControl: true,
scrollZoom: true,
dragRotate: true,
dragPan: true,
keyboard: true,
doubleClickZoom: true,
touchZoomRotate: true,
interactive: true,
showGeolocateControl: false,
useCurrentLocation: false,
geolocateZoom: 10,
showUserLocation: true,
};
function parseBool(value, fallback) {
if (value === undefined) return fallback;
return value === "true" || value === "1";
}
function parseNumber(value, fallback) {
if (value === undefined || value === "") return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function parseCenter(value, fallback) {
if (!value) return fallback;
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.length === 2) return parsed;
} catch {
const parts = value.split(",").map((s) => Number(s.trim()));
if (parts.length === 2 && parts.every(Number.isFinite)) return parts;
}
return fallback;
}
function readOptionsFromElement(el) {
const d = el.dataset;
return {
style: d.style,
center: parseCenter(d.center, undefined),
zoom: parseNumber(d.zoom, undefined),
minZoom: parseNumber(d.minZoom, undefined),
maxZoom: parseNumber(d.maxZoom, undefined),
bearing: parseNumber(d.bearing, undefined),
pitch: parseNumber(d.pitch, undefined),
minPitch: parseNumber(d.minPitch, undefined),
maxPitch: parseNumber(d.maxPitch, undefined),
projection: d.projection,
attributionControl: parseBool(d.attributionControl, undefined),
scrollZoom: parseBool(d.scrollZoom, undefined),
dragRotate: parseBool(d.dragRotate, undefined),
dragPan: parseBool(d.dragPan, undefined),
keyboard: parseBool(d.keyboard, undefined),
doubleClickZoom: parseBool(d.doubleClickZoom, undefined),
touchZoomRotate: parseBool(d.touchZoomRotate, undefined),
interactive: parseBool(d.interactive, undefined),
showGeolocateControl: parseBool(d.showGeolocateControl, undefined),
useCurrentLocation: parseBool(d.useCurrentLocation, undefined),
geolocateZoom: parseNumber(d.geolocateZoom, undefined),
showUserLocation: parseBool(d.showUserLocation, undefined),
};
}
function mergeGlobeOptions(...sources) {
const merged = { ...GLOBE_DEFAULTS };
for (const source of sources) {
if (!source) continue;
for (const [key, value] of Object.entries(source)) {
if (value !== undefined) merged[key] = value;
}
}
return merged;
}
const HIGHLIGHT_SOURCE = "borderless-highlights";
const HIGHLIGHT_LAYER = "borderless-highlight-circles";
const DETAIL_SECTIONS = [
["why_recommended", "Why This Country Is Recommended"],
["feasible_methods", "Feasible Migration Methods"],
["procedure", "Step-by-Step Procedure"],
["requirements", "Requirements And Documents"],
["duration_costs_risks", "Duration, Costs, And Risks"],
["official_sources", "Official Sources"],
];
window.BorderlessGlobe = {
map: null,
markers: [],
markerRecords: [],
lastVersion: -1,
lastDetailsVersion: -1,
pendingState: null,
rootElement: null,
countryDetails: {},
detailModalBackdrop: null,
detailModalTitle: null,
detailModalPathway: null,
detailModalBody: null,
shell() {
return this.rootElement?.querySelector(".globe-shell") ?? null;
},
updateShellState(hasMarkers) {
const shell = this.shell();
if (!shell) return;
shell.classList.toggle("has-markers", Boolean(hasMarkers));
},
clearMarkers() {
for (const marker of this.markers) {
marker.remove();
}
this.markers = [];
this.markerRecords = [];
},
clearHighlights() {
if (!this.map) return;
if (this.map.getLayer(HIGHLIGHT_LAYER)) {
this.map.removeLayer(HIGHLIGHT_LAYER);
}
if (this.map.getSource(HIGHLIGHT_SOURCE)) {
this.map.removeSource(HIGHLIGHT_SOURCE);
}
},
resolveMarkerDetail(marker) {
if (marker.detail) return marker.detail;
if (marker.iso2 && this.countryDetails[marker.iso2]) {
return this.countryDetails[marker.iso2];
}
if (marker.name && this.countryDetails[marker.name]) {
return this.countryDetails[marker.name];
}
return null;
},
ensureDetailModal() {
if (this.detailModalBackdrop) return;
const backdrop = document.createElement("div");
backdrop.className = "globe-detail-backdrop";
backdrop.hidden = true;
const dialog = document.createElement("div");
dialog.className = "globe-detail-dialog";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const header = document.createElement("div");
header.className = "globe-detail-dialog-header";
const titleWrap = document.createElement("div");
titleWrap.className = "globe-detail-dialog-heading";
const titleEl = document.createElement("h2");
titleEl.className = "globe-detail-dialog-title";
const pathwayEl = document.createElement("p");
pathwayEl.className = "globe-detail-dialog-pathway";
titleWrap.appendChild(titleEl);
titleWrap.appendChild(pathwayEl);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "globe-detail-dialog-close";
closeBtn.setAttribute("aria-label", "Close country details");
closeBtn.textContent = "×";
closeBtn.addEventListener("click", () => this.closeCountryDetailModal());
header.appendChild(titleWrap);
header.appendChild(closeBtn);
const body = document.createElement("div");
body.className = "globe-detail-dialog-body markdown-body";
dialog.appendChild(header);
dialog.appendChild(body);
backdrop.appendChild(dialog);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) {
this.closeCountryDetailModal();
}
});
this._detailModalKeyHandler = (event) => {
if (event.key === "Escape" && !backdrop.hidden) {
this.closeCountryDetailModal();
}
};
document.addEventListener("keydown", this._detailModalKeyHandler);
document.body.appendChild(backdrop);
this.detailModalBackdrop = backdrop;
this.detailModalTitle = titleEl;
this.detailModalPathway = pathwayEl;
this.detailModalBody = body;
},
async openCountryDetailModal(detail) {
if (!detail?.sections) return;
this.ensureDetailModal();
this.detailModalTitle.textContent = detail.country || "Country details";
this.detailModalPathway.textContent = detail.pathway || "";
this.detailModalPathway.hidden = !detail.pathway;
const parts = [];
for (const [key, heading] of DETAIL_SECTIONS) {
const content = detail.sections[key];
if (content) {
parts.push(`### ${heading}\n\n${content}`);
}
}
const markdown = parts.join("\n\n");
this.detailModalBody.innerHTML = await renderMarkdownHtml(markdown);
for (const link of this.detailModalBody.querySelectorAll("a")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
this.detailModalBackdrop.hidden = false;
},
closeCountryDetailModal() {
if (this.detailModalBackdrop) {
this.detailModalBackdrop.hidden = true;
}
},
buildPopupElement(marker) {
const detail = this.resolveMarkerDetail(marker);
const status = detail?.status || "pending";
const container = document.createElement("div");
container.className = `globe-marker-popup globe-marker-popup--${status}`;
const title = document.createElement("h4");
title.className = "globe-marker-popup-title";
title.textContent = detail?.country || marker.name || marker.iso2 || "Country";
container.appendChild(title);
if (detail?.pathway) {
const pathwayEl = document.createElement("p");
pathwayEl.className = "globe-marker-popup-pathway";
pathwayEl.textContent = detail.pathway;
container.appendChild(pathwayEl);
}
if (status === "researching") {
const statusBadge = document.createElement("p");
statusBadge.className = "globe-marker-popup-status";
statusBadge.textContent = "Research in progress";
container.appendChild(statusBadge);
const activity = document.createElement("p");
activity.className = "globe-marker-popup-activity";
activity.textContent = `Researching: ${
detail?.current_activity || "Gathering official immigration sources…"
}`;
container.appendChild(activity);
if (detail?.activities?.length > 1) {
const list = document.createElement("ul");
list.className = "globe-marker-popup-activities";
for (const item of detail.activities.slice(-3)) {
const listItem = document.createElement("li");
listItem.textContent = item;
list.appendChild(listItem);
}
container.appendChild(list);
}
} else if (status === "complete") {
if (detail?.preview) {
const preview = document.createElement("p");
preview.className = "globe-marker-popup-preview";
preview.textContent = detail.preview;
container.appendChild(preview);
}
if (detail?.sections && Object.keys(detail.sections).length) {
const viewBtn = document.createElement("button");
viewBtn.type = "button";
viewBtn.className = "globe-marker-popup-view-details";
viewBtn.textContent = "View details";
viewBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void this.openCountryDetailModal(detail);
});
container.appendChild(viewBtn);
}
} else if (status === "incomplete") {
const incomplete = document.createElement("p");
incomplete.className = "globe-marker-popup-incomplete";
incomplete.textContent =
detail?.preview ||
"Research did not produce verified findings for this country.";
container.appendChild(incomplete);
} else {
const waiting = document.createElement("p");
waiting.className = "globe-marker-popup-waiting";
waiting.textContent = "Waiting for research to start…";
container.appendChild(waiting);
}
const hint = document.createElement("p");
hint.className = "globe-marker-popup-hint";
if (status === "researching") {
hint.textContent = "Live tool activity from the country researcher.";
} else if (status === "complete") {
hint.textContent = "Verify key requirements on official immigration sites.";
} else if (status === "incomplete") {
hint.textContent = "Open the parallel research panel in chat for tool logs.";
} else {
hint.textContent = "Details appear as each country research completes.";
}
container.appendChild(hint);
return container;
},
createMarkerPopup(marker) {
const popupNode = this.buildPopupElement(marker);
const popup = new maplibregl.Popup({
offset: 24,
closeButton: true,
closeOnClick: true,
maxWidth: "360px",
className: "borderless-country-popup",
}).setDOMContent(popupNode);
return popup;
},
addMarkers(markers) {
if (!this.map || !markers?.length) return;
markers.forEach((marker) => {
const popup = this.createMarkerPopup(marker);
const instance = new maplibregl.Marker({ color: "#f59e0b" })
.setLngLat([marker.lng, marker.lat])
.setPopup(popup)
.addTo(this.map);
instance.getElement()?.classList.add("globe-country-marker");
instance.getElement()?.setAttribute("role", "button");
instance.getElement()?.setAttribute(
"aria-label",
`View migration details for ${marker.name || marker.iso2}`,
);
this.markers.push(instance);
this.markerRecords.push({ marker, popup, instance });
});
},
updateMarkerPopups(countryDetails) {
this.countryDetails = countryDetails || {};
for (const record of this.markerRecords) {
const mergedMarker = {
...record.marker,
detail: this.resolveMarkerDetail(record.marker),
};
record.marker = mergedMarker;
const popupNode = this.buildPopupElement(mergedMarker);
record.popup.setDOMContent(popupNode);
}
},
addHighlights(highlights) {
if (!this.map || !highlights?.length) return;
const features = highlights.map((item) => ({
type: "Feature",
geometry: {
type: "Point",
coordinates: [item.lng, item.lat],
},
properties: {
iso2: item.iso2,
},
}));
this.map.addSource(HIGHLIGHT_SOURCE, {
type: "geojson",
data: {
type: "FeatureCollection",
features,
},
});
this.map.addLayer({
id: HIGHLIGHT_LAYER,
type: "circle",
source: HIGHLIGHT_SOURCE,
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
1,
18,
4,
28,
8,
42,
],
"circle-color": "#f59e0b",
"circle-opacity": 0.28,
"circle-stroke-width": 2,
"circle-stroke-color": "#fbbf24",
"circle-stroke-opacity": 0.9,
},
});
},
applyFlyTo(flyTo) {
if (!this.map || !flyTo) return;
if (flyTo.bounds) {
this.map.fitBounds(flyTo.bounds, {
padding: flyTo.padding ?? 80,
duration: 1800,
essential: true,
});
return;
}
if (flyTo.center) {
this.map.flyTo({
center: flyTo.center,
zoom: flyTo.zoom ?? 4,
duration: 1800,
essential: true,
});
}
},
applyState(state) {
if (!state) return;
this.pendingState = state;
if (!this.map) return;
const versionChanged = state.version !== this.lastVersion;
const detailsChanged =
(state.details_version || 0) !== this.lastDetailsVersion;
if (!versionChanged && !detailsChanged) return;
this.countryDetails = state.country_details || {};
if (versionChanged) {
this.lastVersion = state.version;
this.clearMarkers();
this.clearHighlights();
this.addMarkers(state.markers || []);
this.addHighlights(state.highlights || []);
this.updateShellState(Boolean(state.markers?.length || state.highlights?.length));
this.applyFlyTo(state.fly_to);
} else if (detailsChanged) {
this.updateMarkerPopups(this.countryDetails);
}
this.lastDetailsVersion = state.details_version || 0;
},
onMapReady() {
if (this.pendingState) {
this.applyState(this.pendingState);
}
},
};
function globeRootElement() {
if (typeof element !== "undefined") {
return element;
}
return document.querySelector(".globe-root");
}
async function initGlobe(options = {}) {
const root = globeRootElement();
if (!root) {
return;
}
try {
await ensureMapLibre();
} catch (err) {
console.error("Globe failed to load MapLibre GL:", err);
root.querySelector(".globe-shell")?.classList.remove("is-loading");
return;
}
const container = root.querySelector(".globe-map");
if (!container || container.dataset.mapInit) return;
container.dataset.mapInit = "1";
const opts = mergeGlobeOptions(readOptionsFromElement(container), options);
const {
projection,
attributionControl,
scrollZoom,
dragRotate,
dragPan,
keyboard,
doubleClickZoom,
touchZoomRotate,
interactive,
showGeolocateControl,
useCurrentLocation,
geolocateZoom,
showUserLocation,
...mapOptions
} = opts;
const map = new maplibregl.Map({
container,
attributionControl,
scrollZoom,
dragRotate,
dragPan,
keyboard,
doubleClickZoom,
touchZoomRotate,
interactive,
...mapOptions,
});
window.BorderlessGlobe.map = map;
window.BorderlessGlobe.rootElement = root;
let userMarker = null;
if (showGeolocateControl) {
map.addControl(
new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: false,
showUserLocation,
showAccuracyCircle: false,
}),
"top-right"
);
}
function flyToCurrentLocation() {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => {
const center = [pos.coords.longitude, pos.coords.latitude];
map.flyTo({
center,
zoom: geolocateZoom,
essential: true,
});
if (showUserLocation) {
if (!userMarker) {
userMarker = new maplibregl.Marker({ color: "#3b82f6" })
.setLngLat(center)
.addTo(map);
} else {
userMarker.setLngLat(center);
}
}
},
(err) => console.warn("Geolocation failed:", err.message),
{ enableHighAccuracy: true }
);
}
map.on("style.load", () => {
map.setProjection({ type: projection });
map.resize();
});
map.on("load", () => {
root.querySelector(".globe-shell")?.classList.remove("is-loading");
root.querySelector(".globe-shell")?.classList.add("is-ready");
map.resize();
if (useCurrentLocation && !window.BorderlessGlobe.pendingState?.fly_to) {
flyToCurrentLocation();
}
window.BorderlessGlobe.onMapReady();
});
window.addEventListener("resize", () => map.resize());
return map;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initGlobe());
} else {
initGlobe();
}