// Chart Builder - Visual chart construction from datasets // Global state for chart builder window.chartBuilderState = null; // Timeout for debounced preview updates let previewUpdateTimeout = null; // 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 { // Fetch dataset from IndexedDB const dataset = await DatasetStorage.getDataset(datasetId); if (!dataset) { showToast('Dataset not found', 'error'); return; } // Initialize state with defaults window.chartBuilderState = { datasetId: datasetId, datasetName: dataset.name, spec: { "$schema": "https://vega.github.io/schema/vega-lite/v5.json", "data": {"name": dataset.name}, "mark": {"type": "bar", "tooltip": true}, "encoding": {} } }; // Populate field dropdowns with dataset columns BEFORE showing modal populateFieldDropdowns(dataset); // Auto-select smart defaults autoSelectDefaults(dataset); // Show modal const modal = document.getElementById('chart-builder-modal'); modal.style.display = 'flex'; // Update URL to reflect chart builder state URLState.update({ view: 'datasets', datasetId: datasetId, action: 'build' }); // Initial preview update (with a small delay to ensure DOM is ready) setTimeout(() => { updateChartBuilderPreview(); }, 50); } catch (error) { console.error('Error opening chart builder:', error); showToast('Error opening chart builder', 'error'); } } // Populate field dropdowns with dataset columns 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); }); }); } // Auto-select smart defaults based on column types function autoSelectDefaults(dataset) { const columns = dataset.columns || []; const columnTypes = 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); setEncoding('x', firstCol, 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); setEncoding('y', secondCol, secondColType ? mapColumnTypeToVegaType(secondColType.type) : 'quantitative'); } } // Set encoding field and type in UI and state function setEncoding(channel, field, type) { // Update dropdown const select = document.getElementById(`encoding-${channel}-field`); if (select) { select.value = field; } // Update type toggle buttons const typeButtons = document.querySelectorAll(`[data-encoding="${channel}"][data-type]`); const activeButton = Array.from(typeButtons).find(btn => btn.dataset.type === type); if (activeButton) { setActiveToggle(typeButtons, activeButton); } // Update state if (!window.chartBuilderState) return; if (field) { window.chartBuilderState.spec.encoding[channel] = { field: field, type: type }; } else { delete window.chartBuilderState.spec.encoding[channel]; } } // Generate Vega-Lite spec from current state function generateVegaLiteSpec() { if (!window.chartBuilderState) return null; const state = window.chartBuilderState; const spec = JSON.parse(JSON.stringify(state.spec)); // Deep clone // Add width/height if specified const width = document.getElementById('chart-width'); const height = document.getElementById('chart-height'); if (width && width.value) { spec.width = parseInt(width.value); } if (height && height.value) { spec.height = parseInt(height.value); } // Remove empty encodings if (Object.keys(spec.encoding).length === 0) { delete spec.encoding; } return spec; } // Validate chart configuration function validateChartConfig() { if (!window.chartBuilderState) return false; const spec = window.chartBuilderState.spec; const encoding = spec.encoding || {}; // At least one encoding must be set const hasEncoding = Object.keys(encoding).length > 0; return hasEncoding; } // Update preview with debouncing function updateChartBuilderPreview() { clearTimeout(previewUpdateTimeout); // Get debounce time from settings (default 1500ms) const debounceTime = getSetting('performance.renderDebounce') || 1500; previewUpdateTimeout = setTimeout(async () => { await renderChartBuilderPreview(); }, debounceTime); } // Render preview in chart builder async function renderChartBuilderPreview() { const previewContainer = document.getElementById('chart-builder-preview'); const errorDiv = document.getElementById('chart-builder-error'); const createBtn = document.getElementById('chart-builder-create-btn'); if (!previewContainer) return; try { // Validate configuration const isValid = validateChartConfig(); if (!isValid) { // Show placeholder previewContainer.innerHTML = '
Configure at least one encoding to see preview
'; if (errorDiv) errorDiv.textContent = ''; if (createBtn) createBtn.disabled = true; return; } // Generate spec const spec = generateVegaLiteSpec(); if (!spec) return; // Resolve dataset references (reuse existing function from editor.js) const resolvedSpec = await resolveDatasetReferences(JSON.parse(JSON.stringify(spec))); // Clear container previewContainer.innerHTML = ''; // Render with Vega-Embed await window.vegaEmbed('#chart-builder-preview', resolvedSpec, { actions: false, renderer: 'svg' }); // Clear error and enable create button if (errorDiv) errorDiv.textContent = ''; if (createBtn) createBtn.disabled = false; } 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'}
`; // Disable create button if (createBtn) createBtn.disabled = true; } } // Create snippet from chart builder async function createSnippetFromBuilder() { if (!window.chartBuilderState) return; try { // Generate final spec const spec = generateVegaLiteSpec(); if (!spec) return; // Create snippet with auto-generated name const snippetName = generateSnippetName(); const now = new Date().toISOString(); const snippet = { id: generateSnippetId(), name: snippetName, created: now, modified: now, spec: spec, draftSpec: null, comment: `Chart built from dataset: ${window.chartBuilderState.datasetName}`, tags: [], datasetRefs: [window.chartBuilderState.datasetName], meta: {} }; // Save snippet SnippetStorage.saveSnippet(snippet); // Close chart builder closeChartBuilder(); // Close dataset modal if open const datasetModal = document.getElementById('dataset-modal'); if (datasetModal) { datasetModal.style.display = 'none'; } // Select and open the new snippet selectSnippet(snippet.id); // Show success message showToast(`Created snippet: ${snippetName}`, 'success'); } catch (error) { console.error('Error creating snippet from builder:', error); showToast('Error creating snippet', 'error'); } } // Close chart builder modal function closeChartBuilder() { const modal = document.getElementById('chart-builder-modal'); modal.style.display = 'none'; // Update URL - go back to dataset view if (window.chartBuilderState && window.chartBuilderState.datasetId) { URLState.update({ view: 'datasets', datasetId: window.chartBuilderState.datasetId, action: null }); } // Clear timeout clearTimeout(previewUpdateTimeout); // Clear state window.chartBuilderState = 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 = ''; } // Reset create button const createBtn = document.getElementById('chart-builder-create-btn'); if (createBtn) { createBtn.disabled = true; } } // Initialize chart builder event listeners function initializeChartBuilder() { // Close button const closeBtn = document.getElementById('chart-builder-modal-close'); if (closeBtn) { closeBtn.addEventListener('click', closeChartBuilder); } // Cancel button const cancelBtn = document.getElementById('chart-builder-cancel-btn'); if (cancelBtn) { cancelBtn.addEventListener('click', closeChartBuilder); } // Back button const backBtn = document.getElementById('chart-builder-back-btn'); if (backBtn) { backBtn.addEventListener('click', () => { closeChartBuilder(); // Dataset modal should still be open }); } // Create snippet button const createBtn = document.getElementById('chart-builder-create-btn'); if (createBtn) { createBtn.addEventListener('click', createSnippetFromBuilder); } // Mark type toggle buttons const markButtons = document.querySelectorAll('.mark-toggle-group .btn-toggle'); markButtons.forEach(btn => { btn.addEventListener('click', () => { setActiveToggle(markButtons, btn); if (window.chartBuilderState) { window.chartBuilderState.spec.mark.type = btn.dataset.mark; updateChartBuilderPreview(); } }); }); // Encoding field dropdowns const encodings = ['x', 'y', 'color', 'size']; encodings.forEach(encoding => { const select = document.getElementById(`encoding-${encoding}-field`); if (select) { select.addEventListener('change', async (e) => { const field = e.target.value; if (!window.chartBuilderState) return; if (field) { // Try to get active type button, or auto-detect from dataset const activeTypeBtn = document.querySelector(`[data-encoding="${encoding}"][data-type].active`); let type = activeTypeBtn ? activeTypeBtn.dataset.type : 'nominal'; // If no active type button, auto-detect from column type if (!activeTypeBtn && window.chartBuilderState.datasetId) { const dataset = await DatasetStorage.getDataset(window.chartBuilderState.datasetId); const columnTypes = dataset.columnTypes || []; const colType = columnTypes.find(ct => ct.name === field); if (colType) { type = mapColumnTypeToVegaType(colType.type); } } setEncoding(encoding, field, type); } else { // Remove encoding when "None" is selected setEncoding(encoding, '', ''); } updateChartBuilderPreview(); }); } }); // Encoding type toggle buttons const typeButtons = document.querySelectorAll('.type-toggle-group .btn-toggle'); typeButtons.forEach(btn => { btn.addEventListener('click', () => { const encoding = btn.dataset.encoding; const type = btn.dataset.type; // Update active state for this encoding's buttons const encodingButtons = document.querySelectorAll(`[data-encoding="${encoding}"][data-type]`); setActiveToggle(encodingButtons, btn); // Update state if (window.chartBuilderState && window.chartBuilderState.spec.encoding[encoding]) { window.chartBuilderState.spec.encoding[encoding].type = type; updateChartBuilderPreview(); } }); }); // Dimension inputs const widthInput = document.getElementById('chart-width'); const heightInput = document.getElementById('chart-height'); if (widthInput) { widthInput.addEventListener('input', () => { updateChartBuilderPreview(); }); } if (heightInput) { heightInput.addEventListener('input', () => { updateChartBuilderPreview(); }); } }