doxa-maps/library/stores/mapStore.js
doxa-maps-88655c70 849cd61022 Make the map's clustering picker real: four options, four different maps
Choosing "Native" in the research map's side menu drew nothing. So did MST, and
so did Network. A control that does nothing is worse than one that is absent:
it tells the reader the map cannot do what it can.

TWO CAUSES, AND FIXING EITHER ALONE WOULD HAVE LOOKED LIKE A FIX.

  1. mapStore never declared `clusteringMode`. The picker wrote to a property no
     reactive system was watching, so useMapClustering's `mode` kept its own
     internal default and the click changed nothing.
  2. The legend's Clusters checkbox force-set 'mst' "regardless of what's in the
     store", with the mode watcher "kept as a no-op for forwards compat" — its
     own words. Even with (1) fixed, one tick of that checkbox would have snapped
     Native back to MST: a control that works until you touch the other one.

So the two controls are now two views of one state. clusteringMode IS the state
('off' means disabled); the picker sets it; the checkbox maps onto it, promoting
only 'off' to 'mst' so the checkbox alone behaves exactly as it always has. They
can no longer show different things. The dim-slider's clusterPinOpacity got a
real home in the same pass, for the same reason it was missing.

★ THE GATE ASSERTS ON WHAT IS DRAWN, NOT ON WHAT IS SET. `clusteringMode ===
'mapbox'` would have passed the moment (1) was fixed, while the map still drew
MST lines — state was exactly the evidence that let an inert control ship. The
map now reports, per clustering layer, whether it exists, whether it is visible
and how many features are rendered; the gate clicks the real buttons and reads
that. MEASURED, before and after, on the same tree:

    before   off 0/0/0   native 0/0/0     mst 0/0/0       network 0/0/0
    after    off 0/0/0   native 37/37/0   mst 0/0/1700    network 0/0/23896
                        (circles/counts/lines)

It also asserts no orphan layers between modes, and that MST and Network differ
— two labels for one behaviour is the same defect as a dead option.

One measurement bug caught on the way, worth recording: a fixed 6-second pause
reported MST as drawing nothing while Network drew thousands of lines. MST over
~2,100 pins is quadratic and had simply not finished. The gate now waits for the
drawing to settle rather than guessing a duration — a gate that calls a slow
feature broken is worse than no gate.
2026-09-25 16:16:46 +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));
}