doxa-maps/library/stores/mapStore.js
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

742 lines
24 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',
/**
* ★ WHICH CLUSTERING ALGORITHM IS DRAWN — and NOT the same thing as `clusterMode` above,
* which is a grouping AXIS ('region' | 'language-family' | …) and is read by nothing today.
* The two names are one letter apart and mean different things, which is exactly why this
* one needs saying out loud rather than being folded into its neighbour.
*
* 'off' | 'mapbox' (native circle clusters) | 'mst' | 'network'. useMapClustering reads it
* (`mode` computed) and ResearchMapSideMenu writes it — but it was never declared HERE, so
* the side menu's four-way picker wrote to a property no reactive system was watching and
* choosing "Native" drew nothing. A control that does nothing is worse than an absent one:
* it tells the reader the map cannot do what it can.
*
* 'off' IS the disabled state, so this single field answers both "is clustering on" and
* "which one" — the legend's Clusters checkbox and this picker are two views of it rather
* than two sources of truth that can disagree.
*/
clusteringMode: 'off',
/**
* Opacity of individual pins while clustering is on (the side menu's dim slider).
* Declared here for the same reason as the mode: the control wrote to a property that did
* not exist, so dragging it changed a number nobody read.
*/
clusterPinOpacity: 0.6,
/**
* 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 the clustering ALGORITHM (see the clusteringMode note in state). useMapClustering
* prefers this action over a direct assignment when it exists, so declaring it keeps the
* composable and the store agreeing about who owns the transition.
* @param {string} mode - 'off' | 'mapbox' | 'mst' | 'network'
*/
setClusteringMode(mode) {
if (['off', 'mapbox', 'mst', 'network'].includes(mode)) {
this.clusteringMode = mode;
this.showClusters = mode !== 'off';
}
},
/**
* 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));
}