|
|
| 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();
|
| }
|
|
|