// Chart Builder - Visual chart construction from datasets
/**
* Alpine.js component for Chart Builder
* Manages reactive state for chart configuration and preview
*/
function chartBuilder() {
return {
// Dataset info
datasetId: null,
datasetName: null,
dataset: null,
// Chart configuration
markType: 'bar',
encodings: {
x: { field: '', type: 'nominal' },
y: { field: '', type: 'quantitative' },
color: { field: '', type: 'nominal' },
size: { field: '', type: 'quantitative' }
},
width: null,
height: null,
// UI state
previewTimeout: null,
// Computed: Generate Vega-Lite spec from current state
get spec() {
const spec = {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": { "name": this.datasetName },
"mark": { "type": this.markType, "tooltip": true },
"encoding": {}
};
// Add encodings
['x', 'y', 'color', 'size'].forEach(channel => {
const enc = this.encodings[channel];
if (enc.field) {
spec.encoding[channel] = {
field: enc.field,
type: enc.type
};
}
});
// Add dimensions if specified
if (this.width) spec.width = parseInt(this.width);
if (this.height) spec.height = parseInt(this.height);
// Remove empty encoding object
if (Object.keys(spec.encoding).length === 0) {
delete spec.encoding;
}
return spec;
},
// Computed: Check if configuration is valid
get isValid() {
return Object.values(this.encodings).some(enc => enc.field !== '');
},
// Load dataset and initialize chart builder
async loadDataset(datasetId) {
try {
// Validate datasetId is provided
if (!datasetId || isNaN(datasetId)) {
console.warn('Chart builder loadDataset called without valid datasetId');
return false;
}
// Fetch dataset from IndexedDB
this.dataset = await DatasetStorage.getDataset(datasetId);
if (!this.dataset) {
Toast.error('Dataset not found');
return false;
}
this.datasetId = datasetId;
this.datasetName = this.dataset.name;
// Populate field dropdowns
populateFieldDropdowns(this.dataset);
// Auto-select smart defaults
this.autoSelectDefaults();
// Trigger initial preview
this.$nextTick(() => {
this.updatePreview();
});
return true;
} catch (error) {
console.error('Error initializing chart builder:', error);
Toast.error('Error opening chart builder');
return false;
}
},
// Auto-select smart defaults based on column types
autoSelectDefaults() {
const columns = this.dataset.columns || [];
const columnTypes = this.dataset.columnTypes || [];
if (columns.length === 0) return;
// Select first column for X axis
if (columns.length >= 1) {
const firstCol = columns[0];
const firstColType = columnTypes.find(ct => ct.name === firstCol);
this.encodings.x.field = firstCol;
this.encodings.x.type = firstColType ? mapColumnTypeToVegaType(firstColType.type) : 'nominal';
}
// Select second column for Y axis (if exists)
if (columns.length >= 2) {
const secondCol = columns[1];
const secondColType = columnTypes.find(ct => ct.name === secondCol);
this.encodings.y.field = secondCol;
this.encodings.y.type = secondColType ? mapColumnTypeToVegaType(secondColType.type) : 'quantitative';
}
},
// Set mark type and update preview
setMarkType(type) {
this.markType = type;
this.updatePreview();
},
// Set encoding field and auto-detect type if needed
async setEncodingField(channel, field) {
this.encodings[channel].field = field;
if (field && this.dataset) {
// Auto-detect type from column type
const columnTypes = this.dataset.columnTypes || [];
const colType = columnTypes.find(ct => ct.name === field);
if (colType) {
this.encodings[channel].type = mapColumnTypeToVegaType(colType.type);
}
}
this.updatePreview();
},
// Set encoding type and update preview
setEncodingType(channel, type) {
if (this.encodings[channel].field) {
this.encodings[channel].type = type;
this.updatePreview();
}
},
// Update preview with debouncing
updatePreview() {
clearTimeout(this.previewTimeout);
// Get debounce time from settings (default 1500ms)
const debounceTime = getSetting('performance.renderDebounce') || 1500;
this.previewTimeout = setTimeout(async () => {
await this.renderPreview();
}, debounceTime);
},
// Render preview in chart builder
async renderPreview() {
const previewContainer = document.getElementById('chart-builder-preview');
const errorDiv = document.getElementById('chart-builder-error');
if (!previewContainer) return;
try {
// Validate configuration
if (!this.isValid) {
previewContainer.innerHTML = '
Configure at least one encoding to see preview
';
if (errorDiv) errorDiv.textContent = '';
return;
}
// Resolve dataset references
const resolvedSpec = await resolveDatasetReferences(JSON.parse(JSON.stringify(this.spec)));
// Clear container
previewContainer.innerHTML = '';
// Render with Vega-Embed
await window.vegaEmbed('#chart-builder-preview', resolvedSpec, {
actions: false,
renderer: 'svg'
});
// Clear error
if (errorDiv) errorDiv.textContent = '';
} catch (error) {
console.error('Error rendering chart preview:', error);
// Show error message
if (errorDiv) {
errorDiv.textContent = error.message || 'Error rendering chart';
}
// Show error in preview
previewContainer.innerHTML = `Error: ${error.message || 'Failed to render chart'}
`;
}
},
// Create snippet from current chart configuration
async createSnippet() {
if (!this.isValid) return;
try {
// Capture current state before closing (important: do this BEFORE close())
const specToSave = JSON.parse(JSON.stringify(this.spec));
const datasetNameToSave = this.datasetName;
const snippetName = generateSnippetName();
const now = new Date().toISOString();
const snippet = {
id: generateSnippetId(),
name: snippetName,
created: now,
modified: now,
spec: specToSave,
draftSpec: specToSave, // Initialize draft with same spec
comment: `Chart built from dataset: ${datasetNameToSave}`,
tags: [],
datasetRefs: [datasetNameToSave],
meta: {}
};
// Save snippet
SnippetStorage.saveSnippet(snippet);
// Close modals
this.close();
const datasetModal = document.getElementById('dataset-modal');
if (datasetModal) datasetModal.style.display = 'none';
// Refresh snippet list and select the new snippet
renderSnippetList();
selectSnippet(snippet.id);
// Show success message
Toast.success(`Created snippet: ${snippetName}`);
} catch (error) {
console.error('Error creating snippet from builder:', error);
Toast.error('Error creating snippet');
}
},
// Close chart builder and cleanup
close() {
const modal = document.getElementById('chart-builder-modal');
modal.style.display = 'none';
// Update URL - go back to dataset view
if (this.datasetId) {
URLState.update({
view: 'datasets',
datasetId: this.datasetId,
action: null
});
}
// Clear timeout
clearTimeout(this.previewTimeout);
// Reset state
this.datasetId = null;
this.datasetName = null;
this.dataset = null;
this.markType = 'bar';
this.encodings = {
x: { field: '', type: 'nominal' },
y: { field: '', type: 'quantitative' },
color: { field: '', type: 'nominal' },
size: { field: '', type: 'quantitative' }
};
this.width = null;
this.height = null;
// Clear preview
const previewContainer = document.getElementById('chart-builder-preview');
if (previewContainer) {
previewContainer.innerHTML = 'Configure chart to see preview
';
}
// Clear error
const errorDiv = document.getElementById('chart-builder-error');
if (errorDiv) errorDiv.textContent = '';
}
};
}
// Map column types to Vega-Lite types
function mapColumnTypeToVegaType(columnType) {
const typeMap = {
'number': 'quantitative',
'date': 'temporal',
'text': 'nominal',
'boolean': 'nominal'
};
return typeMap[columnType] || 'nominal';
}
// Helper: Update active state for a group of toggle buttons
function setActiveToggle(buttons, activeButton) {
buttons.forEach(btn => btn.classList.remove('active'));
activeButton.classList.add('active');
}
// Open chart builder modal with dataset
async function openChartBuilder(datasetId) {
try {
// Show modal first
const modal = document.getElementById('chart-builder-modal');
modal.style.display = 'flex';
// Get Alpine component instance and load dataset
const chartBuilderView = document.getElementById('chart-builder-view');
if (chartBuilderView && chartBuilderView._x_dataStack) {
const component = chartBuilderView._x_dataStack[0];
const success = await component.loadDataset(datasetId);
if (!success) {
modal.style.display = 'none';
return;
}
}
// Update URL to reflect chart builder state
URLState.update({
view: 'datasets',
datasetId: datasetId,
action: 'build'
});
} catch (error) {
console.error('Error opening chart builder:', error);
Toast.error('Error opening chart builder');
}
}
// Populate field dropdowns with dataset columns (utility function)
function populateFieldDropdowns(dataset) {
const encodings = ['x', 'y', 'color', 'size'];
const columns = dataset.columns || [];
encodings.forEach(encoding => {
const select = document.getElementById(`encoding-${encoding}-field`);
if (!select) return;
// Clear existing options except "None"
select.innerHTML = '';
// Add column options
columns.forEach(column => {
const option = document.createElement('option');
option.value = column;
option.textContent = column;
select.appendChild(option);
});
});
}
// closeChartBuilder - now calls Alpine component's close() method
function closeChartBuilder() {
const chartBuilderView = document.getElementById('chart-builder-view');
if (chartBuilderView && chartBuilderView._x_dataStack) {
const component = chartBuilderView._x_dataStack[0];
component.close();
}
}
// Expose functions to global scope for Alpine access
window.openChartBuilder = openChartBuilder;
window.closeChartBuilder = closeChartBuilder;