Standalone repo that builds embeddable JAMstack map bundles (web components), composes them into a lazy-loaded SPA via layouts/, and optionally wraps a Capacitor Android/iOS app — one build, choose your output/CDN. Diátaxis docs. History reset to a single clean commit; no PII, IPs, or internal terms.
704 lines
22 KiB
JavaScript
704 lines
22 KiB
JavaScript
/**
|
|
* Map Store - Centralized state management for all map instances
|
|
*
|
|
* Design Flaw Fixed: Round 5 Q1 - Pinia underutilized (state scattered in MapComponent)
|
|
* Design Flaw Fixed: Round 5 Q2 - Severe prop-drilling (16 props + 10 events)
|
|
* Design Flaw Fixed: Round 5 Q3 - Multiple sources of truth
|
|
* Design Flaw Fixed: Round 5 Q4 - Cannot scale to 100 maps (isolated state)
|
|
*
|
|
* Purpose:
|
|
* - Registry of all active map instances
|
|
* - Shared state (selections, filters, fly state)
|
|
* - Eliminates prop-drilling between Layout → Map → Legend
|
|
* - Enables multi-map coordination
|
|
* - Provides single source of truth for map state
|
|
*
|
|
* REFACTORED: Now uses NPM Pinia package instead of vendored libs
|
|
*/
|
|
|
|
// Import Pinia from NPM package
|
|
import { defineStore, acceptHMRUpdate } from 'pinia';
|
|
|
|
export const useMapStore = defineStore('map', {
|
|
state: () => ({
|
|
// ========================================
|
|
// Map Instance Registry
|
|
// ========================================
|
|
/**
|
|
* Registry of all active map instances
|
|
* Key: mapId (string)
|
|
* Value: {
|
|
* instance: mapboxgl.Map,
|
|
* config: object,
|
|
* isReady: boolean,
|
|
* zoom: number,
|
|
* center: [lng, lat]
|
|
* }
|
|
*/
|
|
maps: {},
|
|
|
|
/**
|
|
* Currently active/focused map ID
|
|
* Used for keyboard shortcuts, global actions
|
|
*/
|
|
activeMapId: null,
|
|
|
|
// ========================================
|
|
// Selection State (Shared across all maps)
|
|
// ========================================
|
|
/**
|
|
* Currently selected DOXA region (null if none)
|
|
* Example: { id: 'Africa', name: 'Africa', ... }
|
|
*/
|
|
selectedRegion: null,
|
|
|
|
/**
|
|
* Currently selected language family (null if none)
|
|
* Example: { id: 'indo-european', name: 'Indo-European', ... }
|
|
*/
|
|
selectedFamily: null,
|
|
|
|
/**
|
|
* Currently selected affinity block (null if none)
|
|
* Example: { code: 'A001', name: 'Arab World', ... }
|
|
*/
|
|
selectedAffinityBlock: null,
|
|
|
|
/**
|
|
* Currently selected language (null if none) — drives Tab 2 of LegendFamilyTree
|
|
* Example: 'Bengali'
|
|
*/
|
|
selectedLanguage: null,
|
|
|
|
/**
|
|
* Currently selected dialect (null if none) — drives Tab 1/2/3 dialect row highlighting
|
|
* Shape: { key: 'family__lang__dialect', originalLabels: ['Arabic, Sudanese'] }
|
|
*/
|
|
selectedDialect: null,
|
|
|
|
/**
|
|
* Active legend tab — 'family' | 'language' | 'dialect'
|
|
* Written by geocoder aggregate-result handler; read by LegendFamilyTree.
|
|
*/
|
|
activeLegendTab: 'family',
|
|
|
|
/**
|
|
* Currently selected gospel resource filter (null if none)
|
|
* Example: 'bible', 'jesusFilm', 'noResources'
|
|
*/
|
|
selectedResource: null,
|
|
|
|
/**
|
|
* Selected people group (for detail view)
|
|
* Example: { uniqueId: 'ROP3_ROG', name: 'Han Chinese', ... }
|
|
*/
|
|
selectedPeopleGroup: null,
|
|
|
|
/**
|
|
* Selected pin IDs (for developer mode export)
|
|
* Array of unique IDs for currently filtered/selected pins
|
|
* Example: ['10001', '10002', '10003']
|
|
*/
|
|
selectedPinIds: [],
|
|
|
|
// ========================================
|
|
// Global Filters (Apply to all maps)
|
|
// ========================================
|
|
// ========================================
|
|
/**
|
|
* Global filters that affect data visibility
|
|
* Example: { status: ['Unreached'], population: { min: 1000 } }
|
|
*/
|
|
globalFilters: {},
|
|
|
|
/**
|
|
* Search query (affects all maps)
|
|
*/
|
|
searchQuery: '',
|
|
|
|
// ========================================
|
|
// Animation State (Auto-fly, Guided Tour)
|
|
// ========================================
|
|
/**
|
|
* Auto-fly animation state
|
|
*/
|
|
autoFly: {
|
|
isActive: false,
|
|
speed: 1.0, // 0.5x - 2.0x
|
|
interval: 5000, // ms between transitions
|
|
currentIndex: 0, // Current item in rotation
|
|
items: [], // Array of regions/families to cycle through
|
|
timerId: null // setInterval ID for cleanup
|
|
},
|
|
|
|
/**
|
|
* Guided tour state
|
|
*/
|
|
guidedTour: {
|
|
isActive: false,
|
|
currentStep: 0,
|
|
steps: [] // Array of tour steps
|
|
},
|
|
|
|
// ========================================
|
|
// Clustering State
|
|
// ========================================
|
|
/**
|
|
* Clustering enabled globally
|
|
*/
|
|
showClusters: false,
|
|
|
|
/**
|
|
* Clustering mode: 'region', 'language-family', 'affinity-block', 'mst'
|
|
*/
|
|
clusterMode: 'region',
|
|
|
|
/**
|
|
* Per-selection MST cluster filter (null when no filter active)
|
|
* clusterFilterProperty: 'languageFamily' | 'doxaRegion' | 'language' | null
|
|
*/
|
|
clusterFilterProperty: null,
|
|
clusterFilterValue: null,
|
|
|
|
/**
|
|
* Auto-fly speed label: 'slow' | 'normal' | 'fast'
|
|
*/
|
|
flySpeed: 'normal',
|
|
|
|
// ========================================
|
|
// Fullscreen State
|
|
// ========================================
|
|
/**
|
|
* Is any map in fullscreen mode?
|
|
* Tracks which map is fullscreen (null if none)
|
|
*/
|
|
fullscreenMapId: null,
|
|
|
|
// ========================================
|
|
// User Preferences (Persisted)
|
|
// ========================================
|
|
/**
|
|
* User preferences loaded from LocalStorage
|
|
*/
|
|
preferences: {
|
|
theme: 'light', // 'light' | 'dark'
|
|
defaultMapStyle: 'streets', // Mapbox style preference
|
|
enableAnimations: true,
|
|
autoSavePreferences: true
|
|
}
|
|
}),
|
|
|
|
getters: {
|
|
/**
|
|
* Get a specific map instance by ID
|
|
*/
|
|
getMapById: (state) => (mapId) => {
|
|
return state.maps[mapId];
|
|
},
|
|
|
|
/**
|
|
* Get the active map instance
|
|
*/
|
|
activeMap: (state) => {
|
|
if (!state.activeMapId) return null;
|
|
return state.maps[state.activeMapId];
|
|
},
|
|
|
|
/**
|
|
* Check if a map is registered
|
|
*/
|
|
hasMap: (state) => (mapId) => {
|
|
return (mapId in state.maps);
|
|
},
|
|
|
|
/**
|
|
* Get all registered map IDs
|
|
*/
|
|
allMapIds: (state) => {
|
|
return Object.keys(state.maps);
|
|
},
|
|
|
|
/**
|
|
* Check if any selection is active
|
|
*/
|
|
hasSelection: (state) => {
|
|
return !!(state.selectedRegion || state.selectedFamily ||
|
|
state.selectedAffinityBlock || state.selectedPeopleGroup);
|
|
},
|
|
|
|
/**
|
|
* Check if auto-fly is active
|
|
*/
|
|
isAutoFlying: (state) => {
|
|
return state.autoFly.isActive;
|
|
},
|
|
|
|
/**
|
|
* Check if guided tour is active
|
|
*/
|
|
isGuidedTourActive: (state) => {
|
|
return state.guidedTour.isActive;
|
|
},
|
|
|
|
/**
|
|
* Check if a specific map is in fullscreen
|
|
*/
|
|
isMapFullscreen: (state) => (mapId) => {
|
|
return state.fullscreenMapId === mapId;
|
|
}
|
|
},
|
|
|
|
actions: {
|
|
// ========================================
|
|
// Map Registration & Lifecycle
|
|
// ========================================
|
|
|
|
/**
|
|
* Register a new map instance
|
|
* @param {string} mapId - Unique map identifier
|
|
* @param {object} instance - Mapbox map instance
|
|
* @param {object} config - Map configuration (optional)
|
|
*/
|
|
registerMap(mapId, instance, config = {}) {
|
|
// Safely get zoom - from config, or from instance, or default
|
|
let zoom = 1.5;
|
|
if (config.zoom !== undefined) {
|
|
zoom = config.zoom;
|
|
} else if (instance && typeof instance.getZoom === 'function') {
|
|
zoom = instance.getZoom();
|
|
}
|
|
|
|
// Safely get center - from config, or from instance, or default
|
|
let center = [0, 0];
|
|
if (config.center) {
|
|
center = config.center;
|
|
} else if (instance && typeof instance.getCenter === 'function') {
|
|
const c = instance.getCenter();
|
|
if (c) {
|
|
center = [c.lng, c.lat];
|
|
}
|
|
}
|
|
|
|
this.maps[mapId] = {
|
|
instance,
|
|
config,
|
|
isReady: false,
|
|
zoom,
|
|
center,
|
|
registeredAt: Date.now()
|
|
};
|
|
|
|
// Set as active if first map
|
|
if (!this.activeMapId) {
|
|
this.activeMapId = mapId;
|
|
}
|
|
|
|
},
|
|
|
|
/**
|
|
* Mark a map as ready (loaded and initialized)
|
|
* @param {string} mapId
|
|
*/
|
|
setMapReady(mapId) {
|
|
const map = this.maps[mapId];
|
|
if (map) {
|
|
map.isReady = true;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update map state (zoom, center)
|
|
* @param {string} mapId
|
|
* @param {object} updates - { zoom?, center? }
|
|
*/
|
|
updateMapState(mapId, updates) {
|
|
const map = this.maps[mapId];
|
|
if (map) {
|
|
Object.assign(map, updates);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Unregister a map instance (cleanup)
|
|
* @param {string} mapId
|
|
*/
|
|
unregisterMap(mapId) {
|
|
delete this.maps[mapId];
|
|
|
|
// If active map was removed, switch to another
|
|
if (this.activeMapId === mapId) {
|
|
const remainingMaps = this.allMapIds;
|
|
this.activeMapId = remainingMaps.length > 0 ? remainingMaps[0] : null;
|
|
}
|
|
|
|
},
|
|
|
|
/**
|
|
* Set the active map
|
|
* @param {string} mapId
|
|
*/
|
|
setActiveMap(mapId) {
|
|
if (mapId in this.maps) {
|
|
this.activeMapId = mapId;
|
|
}
|
|
},
|
|
|
|
// ========================================
|
|
// Selection Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Select a DOXA region
|
|
* @param {string|null} region - Region name string or null to clear
|
|
*/
|
|
selectRegion(region) {
|
|
// Clear other selections (exclusive selection model)
|
|
this.selectedFamily = null;
|
|
this.selectedAffinityBlock = null;
|
|
this.selectedPeopleGroup = null;
|
|
|
|
this.selectedRegion = region;
|
|
},
|
|
|
|
/**
|
|
* Select a language family
|
|
* @param {string|null} family - Family name string or null to clear
|
|
*/
|
|
selectFamily(family) {
|
|
// Clear other selections (legend levels are mutually exclusive — Bug 13)
|
|
this.selectedRegion = null;
|
|
this.selectedAffinityBlock = null;
|
|
this.selectedPeopleGroup = null;
|
|
this.selectedLanguage = null;
|
|
this.selectedDialect = null;
|
|
|
|
this.selectedFamily = family;
|
|
},
|
|
|
|
setActiveLegendTab(tab) {
|
|
this.activeLegendTab = tab;
|
|
},
|
|
|
|
/**
|
|
* Select a specific language (Tab 2 of LegendFamilyTree)
|
|
* @param {string|null} language - Language label string or null to clear
|
|
*/
|
|
selectLanguage(language) {
|
|
// Clear other legend selections (mutually exclusive — Bug 13: clicking a
|
|
// child language while a family is selected swaps the selection cleanly)
|
|
this.selectedRegion = null;
|
|
this.selectedAffinityBlock = null;
|
|
this.selectedPeopleGroup = null;
|
|
this.selectedFamily = null;
|
|
this.selectedDialect = null;
|
|
|
|
this.selectedLanguage = language;
|
|
},
|
|
|
|
/**
|
|
* Select a dialect (Tab 1/2/3 dialect row)
|
|
* @param {{ key: string, originalLabels: string[] }|null} dialect
|
|
*/
|
|
selectDialect(dialect) {
|
|
this.selectedRegion = null;
|
|
this.selectedFamily = null;
|
|
this.selectedLanguage = null;
|
|
this.selectedAffinityBlock = null;
|
|
this.selectedPeopleGroup = null;
|
|
|
|
this.selectedDialect = dialect;
|
|
},
|
|
|
|
/**
|
|
* Select an affinity block
|
|
* @param {object|null} block - Block object or null to clear
|
|
*/
|
|
selectAffinityBlock(block) {
|
|
// Clear other selections
|
|
this.selectedRegion = null;
|
|
this.selectedFamily = null;
|
|
this.selectedPeopleGroup = null;
|
|
|
|
this.selectedAffinityBlock = block;
|
|
},
|
|
|
|
/**
|
|
* Select a people group (detail view)
|
|
* @param {object|null} group - People group object or null to clear
|
|
*/
|
|
selectPeopleGroup(group) {
|
|
this.selectedPeopleGroup = group;
|
|
},
|
|
|
|
/**
|
|
* Select a gospel resource filter
|
|
* @param {string|null} resourceKey - Resource key ('bible', 'jesusFilm', 'noResources') or null to clear
|
|
*/
|
|
selectResource(resourceKey) {
|
|
// Clear other selections
|
|
this.selectedRegion = null;
|
|
this.selectedFamily = null;
|
|
this.selectedAffinityBlock = null;
|
|
this.selectedPeopleGroup = null;
|
|
|
|
// Toggle behavior: clicking same resource clears it
|
|
if (this.selectedResource === resourceKey) {
|
|
this.selectedResource = null;
|
|
} else {
|
|
this.selectedResource = resourceKey;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear all selections and reset map state
|
|
* Call this when switching tabs to prevent cross-tab glitches
|
|
*/
|
|
clearSelections() {
|
|
this.selectedRegion = null;
|
|
this.selectedFamily = null;
|
|
this.selectedLanguage = null;
|
|
this.selectedDialect = null;
|
|
this.selectedAffinityBlock = null;
|
|
this.selectedResource = null;
|
|
this.selectedPeopleGroup = null;
|
|
this.selectedPinIds = [];
|
|
this.showClusters = false;
|
|
this.clusterMode = 'region';
|
|
},
|
|
|
|
/**
|
|
* Set selected pin IDs (Developer Mode export)
|
|
* @param {Array<string>} ids - Array of unique IDs for selected pins
|
|
*/
|
|
setSelectedPinIds(ids) {
|
|
this.selectedPinIds = ids || [];
|
|
},
|
|
|
|
// ========================================
|
|
// Filter Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Set a global filter
|
|
* @param {string} key - Filter key (e.g., 'status', 'population')
|
|
* @param {any} value - Filter value
|
|
*/
|
|
setFilter(key, value) {
|
|
this.globalFilters[key] = value;
|
|
},
|
|
|
|
/**
|
|
* Remove a global filter
|
|
* @param {string} key
|
|
*/
|
|
removeFilter(key) {
|
|
delete this.globalFilters[key];
|
|
},
|
|
|
|
/**
|
|
* Clear all filters
|
|
*/
|
|
clearFilters() {
|
|
this.globalFilters = {};
|
|
this.searchQuery = '';
|
|
},
|
|
|
|
/**
|
|
* Set search query
|
|
* @param {string} query
|
|
*/
|
|
setSearchQuery(query) {
|
|
this.searchQuery = query;
|
|
},
|
|
|
|
// ========================================
|
|
// Animation Actions (Auto-Fly)
|
|
// ========================================
|
|
|
|
/**
|
|
* Start auto-fly animation
|
|
* @param {array} items - Array of regions/families to cycle through
|
|
* @param {number} speed - Speed multiplier (0.5 - 2.0)
|
|
*/
|
|
startAutoFly(items, speed = 1.0) {
|
|
// Stop existing auto-fly
|
|
this.stopAutoFly();
|
|
|
|
this.autoFly.isActive = true;
|
|
this.autoFly.speed = speed;
|
|
this.autoFly.items = items;
|
|
this.autoFly.currentIndex = 0;
|
|
|
|
},
|
|
|
|
/**
|
|
* Stop auto-fly animation
|
|
*/
|
|
stopAutoFly() {
|
|
if (this.autoFly.timerId) {
|
|
clearInterval(this.autoFly.timerId);
|
|
this.autoFly.timerId = null;
|
|
}
|
|
|
|
this.autoFly.isActive = false;
|
|
this.autoFly.currentIndex = 0;
|
|
},
|
|
|
|
/**
|
|
* Update auto-fly speed
|
|
* @param {number} speed - Speed multiplier (0.5 - 2.0)
|
|
*/
|
|
setAutoFlySpeed(speed) {
|
|
this.autoFly.speed = Math.max(0.5, Math.min(2.0, speed));
|
|
},
|
|
|
|
/**
|
|
* Advance to next auto-fly item
|
|
*/
|
|
nextAutoFlyItem() {
|
|
if (!this.autoFly.isActive || this.autoFly.items.length === 0) return;
|
|
|
|
this.autoFly.currentIndex = (this.autoFly.currentIndex + 1) % this.autoFly.items.length;
|
|
const currentItem = this.autoFly.items[this.autoFly.currentIndex];
|
|
|
|
// Trigger selection based on item type
|
|
if (currentItem.type === 'region') {
|
|
this.selectRegion(currentItem);
|
|
} else if (currentItem.type === 'family') {
|
|
this.selectFamily(currentItem);
|
|
}
|
|
},
|
|
|
|
// ========================================
|
|
// Clustering Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Toggle clustering on/off
|
|
* @param {boolean|undefined} value - Optional explicit value, or toggle if undefined
|
|
*/
|
|
toggleClusters(value) {
|
|
if (typeof value === 'boolean') {
|
|
this.showClusters = value;
|
|
} else {
|
|
this.showClusters = !this.showClusters;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set clustering mode
|
|
* @param {string} mode - 'region' | 'language-family' | 'affinity-block' | 'mst' | 'resource'
|
|
*/
|
|
setClusterMode(mode) {
|
|
const validModes = ['region', 'language-family', 'affinity-block', 'mst', 'resource'];
|
|
if (validModes.includes(mode)) {
|
|
this.clusterMode = mode;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set per-selection MST cluster filter
|
|
* @param {string|null} property - 'languageFamily' | 'doxaRegion' | 'language' | null
|
|
* @param {*} value - filter value (string|null)
|
|
*/
|
|
setClusterFilter(property, value) {
|
|
this.clusterFilterProperty = property || null;
|
|
this.clusterFilterValue = value || null;
|
|
},
|
|
|
|
/**
|
|
* Clear the MST cluster filter
|
|
*/
|
|
clearClusterFilter() {
|
|
this.clusterFilterProperty = null;
|
|
this.clusterFilterValue = null;
|
|
},
|
|
|
|
/**
|
|
* Set fly speed label
|
|
* @param {string} speed - 'slow' | 'normal' | 'fast'
|
|
*/
|
|
setFlySpeed(speed) {
|
|
const validSpeeds = ['slow', 'normal', 'fast'];
|
|
if (validSpeeds.includes(speed)) {
|
|
this.flySpeed = speed;
|
|
}
|
|
},
|
|
|
|
// ========================================
|
|
// Selection Aliases (legacy-friendly wrappers)
|
|
// ========================================
|
|
|
|
setSelectedFamily(family) { this.selectFamily(family); },
|
|
clearSelectedFamily() { this.selectFamily(null); },
|
|
setSelectedRegion(region) { this.selectRegion(region); },
|
|
clearSelectedRegion() { this.selectRegion(null); },
|
|
clearSelectedResource() { this.selectResource(null); },
|
|
clearSelectedLanguage() { this.selectLanguage(null); },
|
|
clearSelectedDialect() { this.selectDialect(null); },
|
|
|
|
// ========================================
|
|
// Fullscreen Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Toggle fullscreen for a specific map
|
|
* @param {string} mapId
|
|
*/
|
|
toggleFullscreen(mapId) {
|
|
if (this.fullscreenMapId === mapId) {
|
|
// Exit fullscreen
|
|
this.fullscreenMapId = null;
|
|
} else {
|
|
// Enter fullscreen
|
|
this.fullscreenMapId = mapId;
|
|
}
|
|
},
|
|
|
|
// ========================================
|
|
// User Preferences
|
|
// ========================================
|
|
|
|
/**
|
|
* Load user preferences from LocalStorage
|
|
*/
|
|
loadPreferences() {
|
|
try {
|
|
const stored = localStorage.getItem('mapUserPreferences');
|
|
if (stored) {
|
|
const prefs = JSON.parse(stored);
|
|
this.preferences = { ...this.preferences, ...prefs };
|
|
}
|
|
} catch (error) {
|
|
console.error('[mapStore] Failed to load preferences:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Save user preferences to LocalStorage
|
|
*/
|
|
savePreferences() {
|
|
try {
|
|
localStorage.setItem('mapUserPreferences', JSON.stringify(this.preferences));
|
|
} catch (error) {
|
|
console.error('[mapStore] Failed to save preferences:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update a preference
|
|
* @param {string} key
|
|
* @param {any} value
|
|
*/
|
|
setPreference(key, value) {
|
|
this.preferences[key] = value;
|
|
if (this.preferences.autoSavePreferences) {
|
|
this.savePreferences();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (import.meta.hot) {
|
|
import.meta.hot.accept(acceptHMRUpdate(useMapStore, import.meta.hot));
|
|
}
|