@andreaswissel/uiflow
Version:
Adaptive UI density management library with progressive disclosure, element dependencies, A/B testing, and intelligent behavior-based adaptation
1,433 lines (1,352 loc) โข 71.4 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.UIFlow = {}));
})(this, (function (exports) { 'use strict';
/**
* Base class for UIFlow data sources
*/
class BaseDataSource {
constructor(config = {}) {
this.config = config;
this.ready = false;
}
/**
* Initialize the data source
*/
async initialize() {
this.ready = true;
}
/**
* Check if data source is ready
*/
isReady() {
return this.ready;
}
/**
* Push usage data to the data source
* @param {string} userId - User identifier
* @param {Object} data - Usage data to push
*/
async pushData(userId, data) {
throw new Error('pushData must be implemented by subclasses');
}
/**
* Pull user settings from the data source
* @param {string} userId - User identifier
* @returns {Object} User settings data
*/
async pullData(userId) {
throw new Error('pullData must be implemented by subclasses');
}
/**
* Track a UI interaction event
* @param {string} userId - User identifier
* @param {Object} event - Event data
*/
async trackEvent(userId, event) {
// Default implementation - can be overridden
console.log(`Tracking event for ${userId}:`, event);
}
/**
* Clean up resources
*/
destroy() {
this.ready = false;
}
}
/**
* Dedicated API data source for UIFlow
*/
class APIDataSource extends BaseDataSource {
constructor(config = {}) {
super(config);
this.endpoint = config.endpoint;
if (!this.endpoint) {
throw new Error('API endpoint is required');
}
}
async initialize() {
// Test API connectivity
try {
const response = await fetch(`${this.endpoint}/health`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
console.warn('UIFlow API health check failed, but continuing...');
}
} catch (error) {
console.warn('UIFlow API not accessible:', error.message);
}
this.ready = true;
}
async pushData(userId, data) {
if (!this.isReady()) {
throw new Error('API data source not ready');
}
try {
const response = await fetch(`${this.endpoint}/users/${userId}/ui-density`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...data,
source: 'uiflow-api',
timestamp: Date.now()
})
});
if (!response.ok) {
throw new Error(`API push failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('UIFlow API push error:', error);
throw error;
}
}
async pullData(userId) {
if (!this.isReady()) {
throw new Error('API data source not ready');
}
try {
const response = await fetch(`${this.endpoint}/users/${userId}/ui-density`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
if (response.status === 404) {
// User not found, return empty data
return { areas: {}, overrides: {}, usageHistory: [] };
}
throw new Error(`API pull failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('UIFlow API pull error:', error);
throw error;
}
}
async trackEvent(userId, event) {
if (!this.isReady()) return;
try {
await fetch(`${this.endpoint}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
event: 'ui_interaction',
properties: {
...event,
source: 'uiflow'
},
timestamp: Date.now()
})
});
} catch (error) {
console.warn('UIFlow API event tracking failed:', error);
}
}
}
/**
* Segment data source for UIFlow
*/
class SegmentDataSource extends BaseDataSource {
constructor(config = {}) {
super(config);
this.writeKey = config.writeKey;
this.trackingPlan = config.trackingPlan || 'uiflow';
this.useUserProperties = config.useUserProperties !== false; // Default true
this.analytics = null;
if (!this.writeKey) {
throw new Error('Segment write key is required');
}
}
async initialize() {
// Load Segment Analytics.js if not already loaded
if (!window.analytics) {
await this.loadSegmentScript();
}
this.analytics = window.analytics;
// Initialize Segment if not already done
if (!this.analytics.initialized) {
this.analytics.load(this.writeKey);
}
this.ready = true;
}
async loadSegmentScript() {
return new Promise((resolve, reject) => {
// Segment's analytics.js snippet (simplified)
const analytics = window.analytics = window.analytics || [];
if (analytics.initialize) {
resolve();
return;
}
if (analytics.invoked) {
console.error('Segment snippet included twice.');
reject(new Error('Segment already loaded'));
return;
}
analytics.invoked = true;
analytics.methods = [
'trackSubmit', 'trackClick', 'trackLink', 'trackForm', 'pageview', 'identify',
'reset', 'group', 'track', 'ready', 'alias', 'debug', 'page', 'once', 'off', 'on'
];
analytics.factory = function(method) {
return function() {
const args = Array.prototype.slice.call(arguments);
args.unshift(method);
analytics.push(args);
return analytics;
};
};
for (let i = 0; i < analytics.methods.length; i++) {
const method = analytics.methods[i];
analytics[method] = analytics.factory(method);
}
analytics.load = function(key, options) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.src = 'https://cdn.segment.com/analytics.js/v1/' + key + '/analytics.min.js';
script.onload = resolve;
script.onerror = reject;
const first = document.getElementsByTagName('script')[0];
first.parentNode.insertBefore(script, first);
analytics._loadOptions = options;
};
analytics.SNIPPET_VERSION = '4.13.1';
});
}
async pushData(userId, data) {
if (!this.isReady()) {
throw new Error('Segment data source not ready');
}
// Store UI density preferences as user traits
if (this.useUserProperties) {
this.analytics.identify(userId, {
uiflow_areas: data.areas,
uiflow_last_updated: Date.now(),
uiflow_total_interactions: this.getTotalInteractions(data.usageHistory)
});
}
// Track the settings update event
this.analytics.track('UIFlow Settings Updated', {
userId,
areas: data.areas,
source: this.trackingPlan,
timestamp: Date.now()
});
}
async pullData(userId) {
if (!this.isReady()) {
throw new Error('Segment data source not ready');
}
// Segment doesn't typically provide user data retrieval in the browser
// This would need to be implemented via Segment's Personas API or similar
// For now, return empty data and rely on local storage + tracking
console.warn('Segment data source: pullData not implemented - Segment is primarily for tracking');
return {
areas: {},
overrides: {},
usageHistory: [],
note: 'Segment is write-only from browser. Use Segment Personas API for reading user data.'
};
}
async trackEvent(userId, event) {
if (!this.isReady()) return;
// Map UIFlow events to Segment events
const segmentEvent = this.mapToSegmentEvent(event);
this.analytics.track(segmentEvent.name, {
...segmentEvent.properties,
userId,
source: this.trackingPlan,
timestamp: Date.now()
});
}
mapToSegmentEvent(event) {
const { elementId, category, area, action } = event;
return {
name: 'UI Element Interacted',
properties: {
element_id: elementId,
element_category: category,
ui_area: area,
action_type: action || 'click',
density_level: event.densityLevel,
is_new_feature: event.isNew || false
}
};
}
getTotalInteractions(usageHistory) {
if (!Array.isArray(usageHistory)) return 0;
return usageHistory.reduce((total, [key, interactions]) => {
return total + (Array.isArray(interactions) ? interactions.length : 0);
}, 0);
}
// Segment-specific methods
async identifyUser(userId, traits = {}) {
if (!this.isReady()) return Promise.resolve();
this.analytics.identify(userId, {
...traits,
uiflow_enabled: true,
uiflow_initialized_at: Date.now()
});
return Promise.resolve();
}
trackPageView(userId, page = {}) {
if (!this.isReady()) return;
this.analytics.page({
...page,
uiflow_enabled: true,
source: this.trackingPlan
});
}
trackDensityChange(userId, area, oldDensity, newDensity, reason) {
if (!this.isReady()) return;
this.analytics.track('UIFlow Density Changed', {
userId,
ui_area: area,
old_density: oldDensity,
new_density: newDensity,
change_reason: reason, // 'user_manual', 'auto_adaptation', 'admin_override'
source: this.trackingPlan
});
}
trackFeatureDiscovery(userId, elementId, category, area) {
if (!this.isReady()) return;
this.analytics.track('UIFlow Feature Discovered', {
userId,
element_id: elementId,
element_category: category,
ui_area: area,
source: this.trackingPlan
});
}
}
/**
* Data sources for UIFlow
*/
/**
* Data source manager for handling multiple sources
*/
class DataSourceManager {
constructor() {
this.sources = new Map();
this.primary = null;
}
/**
* Add a data source
* @param {string} name - Data source name
* @param {BaseDataSource} source - Data source instance
* @param {boolean} isPrimary - Whether this is the primary source for reading data
*/
addSource(name, source, isPrimary = false) {
this.sources.set(name, source);
if (isPrimary || !this.primary) {
this.primary = name;
}
}
/**
* Initialize all data sources
*/
async initialize() {
const initPromises = Array.from(this.sources.values()).map(source =>
source.initialize().catch(error => {
console.warn('Data source initialization failed:', error);
return null;
})
);
await Promise.all(initPromises);
}
/**
* Push data to all sources
*/
async pushData(userId, data) {
const pushPromises = Array.from(this.sources.entries()).map(async ([name, source]) => {
try {
if (source.isReady()) {
await source.pushData(userId, data);
}
} catch (error) {
console.warn(`Data push failed for source ${name}:`, error);
}
});
await Promise.all(pushPromises);
}
/**
* Pull data from primary source
*/
async pullData(userId) {
if (!this.primary) {
return { areas: {}, overrides: {}, usageHistory: [] };
}
const primarySource = this.sources.get(this.primary);
if (!primarySource || !primarySource.isReady()) {
return { areas: {}, overrides: {}, usageHistory: [] };
}
try {
return await primarySource.pullData(userId);
} catch (error) {
console.warn('Primary data source pull failed:', error);
return { areas: {}, overrides: {}, usageHistory: [] };
}
}
/**
* Track event in all sources
*/
async trackEvent(userId, event) {
const trackPromises = Array.from(this.sources.values()).map(async (source) => {
try {
if (source.isReady()) {
await source.trackEvent(userId, event);
}
} catch (error) {
console.warn('Event tracking failed for source:', error);
}
});
await Promise.all(trackPromises);
}
/**
* Get source by name
*/
getSource(name) {
return this.sources.get(name);
}
/**
* Get all source names
*/
getSourceNames() {
return Array.from(this.sources.keys());
}
/**
* Remove a source
*/
removeSource(name) {
const source = this.sources.get(name);
if (source) {
source.destroy();
this.sources.delete(name);
if (this.primary === name) {
// Set new primary to first available source
this.primary = this.sources.size > 0 ? this.sources.keys().next().value : null;
}
}
}
/**
* Destroy all sources
*/
destroy() {
for (const source of this.sources.values()) {
source.destroy();
}
this.sources.clear();
this.primary = null;
}
}
/**
* UIFlow - Adaptive UI density management library
* Licensed under CC BY-NC 4.0
*/
class UIFlow {
constructor(options = {}) {
this.sequenceTracker = new Map(); // Track element interaction sequences
this.activeRules = [];
this.abTestMetrics = new Map();
this.config = {
categories: ['basic', 'advanced', 'expert'],
learningRate: 0.1,
storageKey: 'uiflow-data',
timeAcceleration: 1,
adaptationThreshold: 3,
decayRate: 0.95,
syncInterval: 5 * 60 * 1000,
userId: null,
dataSources: {},
...options
};
this.elements = new Map();
this.areas = new Map();
this.usageHistory = new Map();
this.defaultDensity = 0.3;
this.initialized = false;
this.syncTimer = null;
this.remoteOverrides = new Map();
this.highlights = new Map();
this.highlightStyles = null;
this.dataManager = new DataSourceManager();
}
/**
* Initialize UIFlow with configuration
*/
async init(options = {}) {
Object.assign(this.config, options);
this.loadStoredData();
this.setupObservers();
this.injectHighlightStyles();
// Initialize data sources
await this.setupDataSources();
// Sync with data sources if configured
if (this.config.userId && this.dataManager.getSourceNames().length > 0) {
await this.syncWithDataSources();
this.startSyncTimer();
}
this.initialized = true;
// Emit initialization event
this.emit('uiflow:initialized', { areas: this.getAreaDensities() });
return this;
}
/**
* Categorize a UI element with optional area specification
*/
categorize(element, category, area = 'default', options = {}) {
if (!this.config.categories.includes(category)) {
throw new Error(`Invalid category: ${category}`);
}
const id = this.getElementId(element);
this.elements.set(id, {
element,
category,
area,
visible: true,
interactions: 0,
lastUsed: null,
helpText: options.helpText,
isNew: options.isNew ?? false,
dependencies: options.dependencies ?? []
});
// Initialize area if not exists
if (!this.areas.has(area)) {
this.areas.set(area, {
density: this.defaultDensity,
lastActivity: Date.now(),
totalInteractions: 0
});
}
element.setAttribute('data-uiflow-category', category);
element.setAttribute('data-uiflow-area', area);
element.setAttribute('data-uiflow-id', id);
if (options.helpText) {
element.setAttribute('data-uiflow-help', options.helpText);
}
this.updateElementVisibility(id);
return this;
}
/**
* Get density level for specific area (0-1)
* Checks remote overrides first, then local data
*/
getDensityLevel(area = 'default') {
// Check for remote override first
if (this.remoteOverrides.has(area)) {
return this.remoteOverrides.get(area);
}
const areaData = this.areas.get(area);
return areaData ? areaData.density : this.defaultDensity;
}
/**
* Get all area densities
*/
getAreaDensities() {
const densities = {};
for (const [area, data] of this.areas) {
densities[area] = data.density;
}
return densities;
}
/**
* Set density level for specific area manually
*/
setDensityLevel(level, area = 'default', options = {}) {
const clampedLevel = Math.max(0, Math.min(1, level));
if (!this.areas.has(area)) {
this.areas.set(area, {
density: clampedLevel,
lastActivity: Date.now(),
totalInteractions: 0
});
}
else {
this.areas.get(area).density = clampedLevel;
}
this.updateAreaElementsVisibility(area);
this.emit('uiflow:density-changed', {
area,
density: clampedLevel,
areas: this.getAreaDensities()
});
// Push to data sources unless explicitly skipped
if (!options.skipAPI && this.config.userId) {
this.pushToDataSources();
}
return this;
}
/**
* Check if element should be visible based on current density
*/
shouldShowElement(category, area = 'default') {
const categoryIndex = this.config.categories.indexOf(category);
// More realistic thresholds: basic=0%, advanced=40%, expert=75%
const thresholds = {
0: 0.0, // basic: 0%
1: 0.4, // advanced: 40%
2: 0.75 // expert: 75%
};
const threshold = thresholds[categoryIndex] ?? (categoryIndex / (this.config.categories.length - 1));
const areaDensity = this.getDensityLevel(area);
return areaDensity >= threshold;
}
/**
* Enhanced element visibility check including dependencies
*/
shouldShowElementWithDependencies(elementId) {
const element = this.elements.get(elementId);
if (!element)
return false;
// First check density-based visibility
const densityVisible = this.shouldShowElement(element.category, element.area);
if (!densityVisible)
return false;
// Then check dependencies
return this.validateDependencies(elementId);
}
/**
* Override density for specific area (admin control)
*/
setRemoteOverride(area, density) {
this.remoteOverrides.set(area, Math.max(0, Math.min(1, density)));
this.updateAreaElementsVisibility(area);
this.emit('uiflow:override-applied', { area, density });
}
/**
* Remove remote override for area
*/
clearRemoteOverride(area) {
this.remoteOverrides.delete(area);
this.updateAreaElementsVisibility(area);
this.emit('uiflow:override-cleared', { area });
}
/**
* Get current override status
*/
getOverrides() {
return Object.fromEntries(this.remoteOverrides);
}
/**
* Check if area has remote override
*/
hasOverride(area) {
return this.remoteOverrides.has(area);
}
/**
* Highlight an element with specific style and optional tooltip
*/
highlightElement(elementId, style = 'default', options = {}) {
const data = this.elements.get(elementId);
if (!data || !data.visible)
return this;
const highlightInfo = {
style,
startTime: Date.now(),
duration: options.duration ?? 5000,
tooltip: options.tooltip ?? data.helpText ?? undefined,
persistent: options.persistent ?? false,
onDismiss: options.onDismiss
};
this.highlights.set(elementId, highlightInfo);
this.applyHighlight(elementId, highlightInfo);
// Auto-remove highlight after duration (unless persistent)
if (!highlightInfo.persistent) {
setTimeout(() => {
this.removeHighlight(elementId);
}, highlightInfo.duration);
}
this.emit('uiflow:highlight-added', { elementId, style, options });
return this;
}
/**
* Remove highlight from element
*/
removeHighlight(elementId) {
const highlightInfo = this.highlights.get(elementId);
if (!highlightInfo)
return this;
const data = this.elements.get(elementId);
if (data) {
data.element.classList.remove(`uiflow-highlight-${highlightInfo.style}`);
data.element.removeAttribute('data-uiflow-tooltip');
// Remove tooltip if it exists
const tooltip = data.element.querySelector('.uiflow-tooltip');
if (tooltip) {
tooltip.remove();
}
}
this.highlights.delete(elementId);
if (highlightInfo.onDismiss) {
highlightInfo.onDismiss(elementId);
}
this.emit('uiflow:highlight-removed', { elementId });
return this;
}
/**
* Flag element as new with optional help text
*/
flagAsNew(elementId, helpText, duration = 8000) {
const data = this.elements.get(elementId);
if (!data)
return this;
data.isNew = true;
if (helpText) {
data.helpText = helpText;
data.element.setAttribute('data-uiflow-help', helpText);
}
// If element is currently visible, highlight it immediately
if (data.visible) {
this.highlightElement(elementId, 'new-feature', {
duration,
tooltip: helpText ?? 'New feature available!'
});
}
return this;
}
/**
* Show tooltip for element
*/
showTooltip(elementId, text, options = {}) {
return this.highlightElement(elementId, 'tooltip', {
tooltip: text,
duration: options.duration ?? 3000,
persistent: options.persistent ?? false
});
}
/**
* Clear all highlights
*/
clearAllHighlights() {
for (const elementId of this.highlights.keys()) {
this.removeHighlight(elementId);
}
return this;
}
/**
* Get currently highlighted elements
*/
getHighlights() {
return Array.from(this.highlights.keys());
}
/**
* Add a data source dynamically
*/
addDataSource(name, type, config, isPrimary = false) {
let source;
switch (type) {
case 'api':
source = new APIDataSource({ endpoint: config.endpoint });
break;
case 'segment':
source = new SegmentDataSource({
writeKey: config.writeKey,
trackingPlan: config.trackingPlan,
useUserProperties: config.useUserProperties
});
break;
default:
throw new Error(`Unknown data source type: ${type}`);
}
this.dataManager.addSource(name, source, isPrimary);
source.initialize();
this.emit('uiflow:datasource-added', { sources: [name] });
return this;
}
/**
* Remove a data source
*/
removeDataSource(name) {
this.dataManager.removeSource(name);
this.emit('uiflow:datasource-removed', { sources: [name] });
return this;
}
/**
* Get data source by name (for advanced usage)
*/
getDataSource(name) {
return this.dataManager.getSource(name);
}
/**
* Force sync with data sources and apply updates
*/
async forceSync() {
await this.syncWithDataSources();
await this.pushToDataSources();
}
/**
* Simulate usage over time for testing
*/
simulateUsage(area, interactions, daysToSimulate = 7) {
const originalAcceleration = this.config.timeAcceleration;
this.config.timeAcceleration = 1000; // Speed up for simulation
const msPerDay = 24 * 60 * 60 * 1000;
const totalMs = daysToSimulate * msPerDay;
const intervalMs = totalMs / interactions.length;
interactions.forEach((interaction, index) => {
const simulatedTime = Date.now() + (index * intervalMs);
this.recordUsageHistory(area, interaction.category, simulatedTime * this.config.timeAcceleration);
});
// Trigger adaptation after simulation
this.adaptDensity(area);
this.config.timeAcceleration = originalAcceleration;
}
/**
* Simulate realistic user behavior patterns
*/
simulateUserType(type, areas) {
const predefinedPatterns = {
beginner: { basic: 0.85, advanced: 0.13, expert: 0.02 },
intermediate: { basic: 0.60, advanced: 0.35, expert: 0.05 },
'power-user': { basic: 0.30, advanced: 0.50, expert: 0.20 },
expert: { basic: 0.15, advanced: 0.40, expert: 0.45 }
};
const pattern = typeof type === 'string' ? predefinedPatterns[type] : type;
const targetAreas = areas ? (Array.isArray(areas) ? areas : [areas]) : Array.from(this.areas.keys());
// Generate interactions based on pattern (50 total for realistic simulation)
const totalInteractions = 50;
const interactions = [];
const basicCount = Math.round(totalInteractions * pattern.basic);
const advancedCount = Math.round(totalInteractions * pattern.advanced);
const expertCount = totalInteractions - basicCount - advancedCount;
// Add interactions in shuffled order for realism
for (let i = 0; i < basicCount; i++)
interactions.push({ category: 'basic' });
for (let i = 0; i < advancedCount; i++)
interactions.push({ category: 'advanced' });
for (let i = 0; i < expertCount; i++)
interactions.push({ category: 'expert' });
// Shuffle for realistic distribution
for (let i = interactions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = interactions[i];
if (interactions[j] !== undefined && temp !== undefined) {
interactions[i] = interactions[j];
interactions[j] = temp;
}
}
// Apply to all target areas
targetAreas.forEach(area => {
this.simulateUsage(area, interactions, 7);
});
console.log(`๐ค Simulated ${typeof type === 'string' ? type : 'custom'} user for areas: ${targetAreas.join(', ')}`);
}
/**
* Enable/disable demo mode for testing
*/
setDemoMode(enabled) {
// Store original config
if (!this.demoModeOriginal) {
this.demoModeOriginal = {
learningRate: this.config.learningRate,
timeAcceleration: this.config.timeAcceleration,
adaptationThreshold: this.config.adaptationThreshold
};
}
if (enabled) {
this.config.learningRate = 0.9;
this.config.timeAcceleration = 2;
this.config.adaptationThreshold = 1;
}
else {
Object.assign(this.config, this.demoModeOriginal);
}
}
/**
* Directly set density for demos (bypasses learning)
*/
boostDensity(area, targetDensity) {
const areaData = this.areas.get(area);
if (areaData) {
const oldDensity = areaData.density;
areaData.density = Math.max(0, Math.min(1, targetDensity));
this.updateAreaElementsVisibility(area);
this.saveData();
this.emit('uiflow:density-changed', {
area,
density: areaData.density,
previousDensity: oldDensity
});
console.log(`๐ฏ Boosted ${area} density to ${Math.round(targetDensity * 100)}%`);
}
}
/**
* Reset area to initial state
*/
resetArea(area) {
const areaData = this.areas.get(area);
if (areaData) {
areaData.density = 0.3; // Default density
areaData.totalInteractions = 0;
areaData.lastActivity = 0;
// Clear usage history for this area
for (const category of this.config.categories) {
const key = `${area}:${category}`;
this.usageHistory.delete(key);
}
this.updateAreaElementsVisibility(area);
this.saveData();
console.log(`๐ Reset ${area} to initial state`);
}
}
/**
* Get comprehensive stats for an area
*/
getAreaStats(area) {
const areaData = this.areas.get(area);
if (!areaData) {
return { density: 0, visibleElements: 0, totalElements: 0, recentUsage: { basic: 0, advanced: 0, expert: 0 }, adaptationEvents: 0 };
}
const elements = Array.from(this.elements.values()).filter(el => el.area === area);
const visibleElements = elements.filter(el => el.visible).length;
const recentUsage = this.getRecentUsageByArea(area);
return {
density: areaData.density,
visibleElements,
totalElements: elements.length,
recentUsage,
adaptationEvents: areaData.totalInteractions
};
}
/**
* Get stats for all areas
*/
getOverviewStats() {
const stats = {};
for (const area of this.areas.keys()) {
stats[area] = this.getAreaStats(area);
}
return stats;
}
/**
* Public getters for controlled access to private properties
*/
getElementCount() {
return this.elements.size;
}
getAreaCount() {
return this.areas.size;
}
getElementsForArea(area) {
return Array.from(this.elements.values()).filter(el => el.area === area);
}
isInitialized() {
return this.initialized;
}
/**
* Get elements for demo features (controlled access)
*/
getNewVisibleElements() {
return Array.from(this.elements.entries())
.filter(([_, data]) => data.isNew && data.visible)
.map(([elementId, data]) => ({ elementId, helpText: data.helpText }));
}
getVisibleElementsWithHelp() {
return Array.from(this.elements.entries())
.filter(([_, data]) => data.visible && data.helpText)
.map(([elementId, data]) => ({ elementId, helpText: data.helpText }));
}
getVisibleElementsSorted() {
return Array.from(this.elements.entries())
.filter(([_, data]) => data.visible && data.helpText)
.sort(([, a], [, b]) => {
const order = { basic: 0, advanced: 1, expert: 2 };
return order[a.category] - order[b.category];
})
.map(([elementId, data]) => ({ elementId, category: data.category, helpText: data.helpText }));
}
/**
* Validate element dependencies
*/
validateDependencies(elementId) {
const elementData = this.elements.get(elementId);
if (!elementData || !elementData.dependencies) {
return true; // No dependencies = always valid
}
return elementData.dependencies.every(dep => this.validateSingleDependency(dep));
}
validateSingleDependency(dependency) {
switch (dependency.type) {
case 'usage_count':
return this.validateUsageCount(dependency);
case 'sequence':
return this.validateSequence(dependency);
case 'time_based':
return this.validateTimeBased(dependency);
case 'logical_and':
return dependency.elements?.every(elemId => this.validateDependencies(elemId)) ?? false;
case 'logical_or':
return dependency.elements?.some(elemId => this.validateDependencies(elemId)) ?? false;
default:
console.warn(`Unknown dependency type: ${dependency.type}`);
return false;
}
}
validateUsageCount(dependency) {
if (!dependency.elementId || !dependency.threshold)
return false;
const targetElement = this.elements.get(dependency.elementId);
if (!targetElement)
return false;
return targetElement.interactions >= dependency.threshold;
}
validateSequence(dependency) {
if (!dependency.elements || dependency.elements.length === 0)
return false;
// Check if user has interacted with elements in the specified sequence
const sequenceKey = dependency.elements.join('โ');
this.sequenceTracker.get(sequenceKey) || [];
// Simple validation: all elements in sequence have been used
return dependency.elements.every(elemId => {
const element = this.elements.get(elemId);
return element && element.interactions > 0;
});
}
validateTimeBased(dependency) {
if (!dependency.elementId || !dependency.timeWindow || !dependency.minUsage) {
return false;
}
const targetElement = this.elements.get(dependency.elementId);
if (!targetElement)
return false;
// Parse time window (e.g., '7d', '30d', '1w')
const timeWindowMs = this.parseTimeWindow(dependency.timeWindow);
const now = this.getAcceleratedTime();
const cutoff = now - timeWindowMs;
// Count recent usage for this element
const area = targetElement.area;
const category = targetElement.category;
const key = `${area}:${category}`;
const history = this.usageHistory.get(key) || [];
const recentUsage = history.filter(timestamp => timestamp > cutoff).length;
return recentUsage >= dependency.minUsage;
}
parseTimeWindow(timeWindow) {
const match = timeWindow.match(/^(\d+)([dwmy])$/);
if (!match)
return 7 * 24 * 60 * 60 * 1000; // Default to 7 days
const [, amount, unit] = match;
const value = parseInt(amount || '7', 10);
switch (unit) {
case 'd': return value * 24 * 60 * 60 * 1000;
case 'w': return value * 7 * 24 * 60 * 60 * 1000;
case 'm': return value * 30 * 24 * 60 * 60 * 1000;
case 'y': return value * 365 * 24 * 60 * 60 * 1000;
default: return 7 * 24 * 60 * 60 * 1000;
}
}
/**
* Track element sequences for dependency validation and user journey analysis
*/
trackElementSequence(elementId) {
const element = this.elements.get(elementId);
if (!element)
return;
const timestamp = Date.now();
const areaSequenceKey = `${element.area}:sequence`;
const globalSequenceKey = 'global:sequence';
// Track area-specific sequence
this.addToSequence(areaSequenceKey, { elementId, timestamp, area: element.area });
// Track global sequence across all areas
this.addToSequence(globalSequenceKey, { elementId, timestamp, area: element.area });
// Analyze and detect common patterns
this.analyzeUserJourney(element.area);
}
addToSequence(key, entry) {
if (!this.sequenceTracker.has(key)) {
this.sequenceTracker.set(key, []);
}
const sequence = this.sequenceTracker.get(key);
sequence.push(entry.timestamp);
// Keep only last 20 interactions in sequence for pattern analysis
if (sequence.length > 20) {
sequence.splice(0, sequence.length - 20);
}
}
/**
* Analyze user journey patterns and detect competency progression
*/
analyzeUserJourney(area) {
const sequenceKey = `${area}:sequence`;
const sequence = this.sequenceTracker.get(sequenceKey) || [];
if (sequence.length < 5)
return; // Need minimum interactions for pattern analysis
// Detect usage patterns
const recentInteractions = sequence.slice(-10);
const timeSpan = (recentInteractions[recentInteractions.length - 1] || 0) - (recentInteractions[0] || 0);
const interactionRate = recentInteractions.length / (timeSpan / 1000 / 60); // per minute
// Classify user behavior
let userBehavior = 'exploring';
if (interactionRate > 2) {
userBehavior = 'focused'; // High interaction rate suggests focused usage
}
const areaElements = this.getElementsForArea(area);
const advancedUsage = areaElements.filter(el => (el.category === 'advanced' || el.category === 'expert') && el.interactions > 0).length;
if (advancedUsage > areaElements.length * 0.5) {
userBehavior = 'expert'; // Using >50% advanced features
}
// Emit journey insights
this.emit('uiflow:journey-analyzed', {
area,
behavior: userBehavior,
interactionRate,
advancedFeatureUsage: advancedUsage,
totalSequenceLength: sequence.length,
recentActivity: recentInteractions.length
});
// Trigger adaptive responses based on detected patterns
this.adaptToUserJourney(area, userBehavior);
}
/**
* Adapt UIFlow behavior based on detected user journey patterns
*/
adaptToUserJourney(area, behavior) {
switch (behavior) {
case 'exploring':
// User is exploring - show helpful hints and basic features
this.flagNewElementsInArea(area, 'basic');
break;
case 'focused':
// User is working focused - reduce interruptions, show relevant advanced features
this.boostDensity(area, Math.min(this.getDensityLevel(area) + 0.1, 0.8));
break;
case 'expert':
// User is expert - unlock expert features, minimal hand-holding
this.unlockCategory('expert', area);
break;
}
}
/**
* Flag new elements in an area for discovery
*/
flagNewElementsInArea(area, category) {
for (const [elementId, element] of this.elements) {
if (element.area === area && element.visible) {
if (!category || element.category === category) {
element.isNew = true;
this.flagAsNew(elementId, element.helpText || 'Try this feature!', 5000);
}
}
}
}
/**
* Load configuration from JSON with optional A/B testing
*/
async loadConfiguration(config) {
let finalConfig = config;
// Handle A/B testing if enabled
if (config.abTest?.enabled) {
const selectedVariant = this.selectABTestVariant(config.abTest);
if (selectedVariant) {
finalConfig = this.mergeConfiguration(config, selectedVariant.configuration);
this.abTestVariant = selectedVariant.id;
this.initializeABTestMetrics(config.abTest.metrics);
console.log(`๐งช A/B Test Active: ${config.abTest.testId}, Variant: ${selectedVariant.name}`);
}
}
this.loadedConfiguration = finalConfig;
// Apply area configurations
for (const [areaId, areaConfig] of Object.entries(finalConfig.areas)) {
// Set default density for area
if (!this.areas.has(areaId)) {
this.areas.set(areaId, {
density: areaConfig.defaultDensity,
lastActivity: Date.now(),
totalInteractions: 0
});
}
// Auto-categorize elements from configuration
for (const elementConfig of areaConfig.elements) {
const element = document.querySelector(elementConfig.selector);
if (element) {
this.categorize(element, elementConfig.category, areaId, {
helpText: elementConfig.helpText,
dependencies: elementConfig.dependencies
});
}
else {
console.warn(`Element not found for selector: ${elementConfig.selector}`);
}
}
}
// Initialize rule engine if rules are present
if (finalConfig.rules && finalConfig.rules.length > 0) {
this.initializeRuleEngine(finalConfig.rules);
}
console.log(`โ
UIFlow configuration loaded: ${finalConfig.name} v${finalConfig.version}`);
this.emit('uiflow:configuration-loaded', { config: finalConfig, abTestVariant: this.abTestVariant });
return this;
}
/**
* Select A/B test variant based on traffic allocation
*/
selectABTestVariant(abTest) {
if (!abTest.variants.length || !abTest.trafficAllocation.length) {
return null;
}
// Generate a consistent hash based on user ID for stable variant assignment
const userId = this.config.userId || 'anonymous';
const hash = this.hashString(userId + abTest.testId);
const percentage = hash % 100;
// Select variant based on traffic allocation
let cumulativePercentage = 0;
for (let i = 0; i < abTest.variants.length; i++) {
cumulativePercentage += abTest.trafficAllocation[i] || 0;
if (percentage < cumulativePercentage) {
return abTest.variants[i] || null;
}
}
return abTest.variants[0] || null; // Fallback to first variant
}
hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Merge base configuration with variant configuration
*/
mergeConfiguration(base, variant) {
return {
...base,
...variant,
areas: {
...base.areas,
...variant.areas
},
rules: variant.rules || base.rules || [],
templates: variant.templates || base.templates || []
};
}
/**
* Initialize A/B test metrics tracking
*/
initializeABTestMetrics(metrics) {
for (const metric of metrics) {
this.abTestMetrics.set(metric, 0);
}
}
/**
* Track A/B test metrics
*/
trackABTestMetric(metric, value = 1) {
if (this.abTestVariant && this.abTestMetrics.has(metric)) {
const currentValue = this.abTestMetrics.get(metric) || 0;
this.abTestMetrics.set(metric, currentValue + value);
this.emit('uiflow:ab-test-metric', {
testVariant: this.abTestVariant,
metric,
value: currentValue + value
});
}
}
/**
* Get A/B test results
*/
getABTestResults() {
const metrics = {};
for (const [key, value] of this.abTestMetrics) {
metrics[key] = value;
}
return {
variant: this.abTestVariant,
metrics
};
}
/**
* Export current configuration
*/
exportConfiguration() {
if (this.loadedConfiguration) {
return this.loadedConfiguration;
}
// Generate configuration from current state
const areas = {};
for (const [areaId, areaData] of this.areas) {
const elements = this.getElementsForArea(areaId).map(elementData => ({
id: elementData.element.getAttribute('data-uiflow-id') || '',
selector: `#${elementData.element.id}` || `.${elementData.element.className}`,
category: elementData.category,
helpText: elementData.helpText,
dependencies: elementData.dependencies
}));
areas[areaId] = {
defaultDensity: areaData.density,
elements
};
}
return {
name: 'Generated UIFlow Configuration',
version: '1.0.0',
areas,
rules: []
};
}
/**
* Initialize rule engine with configuration rules
*/
initializeRuleEngine(rules) {
this.activeRules = rules;
// Start periodic rule checking (every 30 seconds)
this.ruleCheckTimer = window.setInterval(() => {
this.processRules();
}, 30000);
console.log(`๐ง Rule engine initialized with ${rules.length} rules`);
}
/**
* Process all active rules
*/
processRules() {
for (const rule of this.activeRules) {
if (this.evaluateRuleTrigger(rule.trigger)) {
this.executeRuleAction(rule.action);
console.log(`๐ Rule executed: ${rule.name}`);
this.emit('uiflow:rule-triggered', {
rule: rule.name,
trigger: rule.trigger,
action: rule.action
});
}
}
}
/**
* Evaluate if a rule trigger condition is met
*/
evaluateRuleTrigger(trigger) {
switch (trigger.type) {
case 'usage_pattern':
return this.evaluateUsagePattern(trigger);
case 'time_based':
return this.evaluateTimeBased(trigger);
case 'element_interaction':
return this.evaluateElementInteraction(trigger);
case 'custom_event':
// Custom events would be handled via external triggers
return false;
default:
console.warn(`Unknown trigger type: ${trigger.type}`);
return false;
}
}
evaluateUsagePattern(trigger) {
if (!trigger.elements || !trigger.frequency || !trigger.duration) {
return false;
}
const durationMs = this.parseTimeWindow(trigger.duration);
const now = this.getAcceleratedTime();
const cutoff = now - durationMs;
// Check if all specified elements have been used according to frequency
return trigger.elements.every(elementId => {
const element = this.elements.get(elementId);
if (!element)
return false;
const area = element.area;
c