refactor: migrate chart builder UI to Alpine.js for reactive state management

This commit is contained in:
2025-11-24 23:57:06 +02:00
parent 86c9a81653
commit 4c6fe5b9bd
3 changed files with 358 additions and 439 deletions

View File

@@ -474,27 +474,27 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<span class="modal-title">Build Chart</span> <span class="modal-title">Build Chart</span>
<button class="btn btn-icon" id="chart-builder-modal-close" title="Close chart builder (Escape)">×</button> <button class="btn btn-icon" id="chart-builder-modal-close" @click="$el.closest('#chart-builder-view')._x_dataStack[0].close()" title="Close chart builder (Escape)">×</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Chart Builder View --> <!-- Chart Builder View -->
<div id="chart-builder-view" class="chart-builder-view"> <div id="chart-builder-view" class="chart-builder-view" x-data="chartBuilder()">
<div class="chart-builder-container"> <div class="chart-builder-container">
<!-- Left Panel: Configuration --> <!-- Left Panel: Configuration -->
<div class="chart-builder-config"> <div class="chart-builder-config">
<div class="chart-builder-header"> <div class="chart-builder-header">
<button class="btn btn-modal" id="chart-builder-back-btn" title="Back to dataset details">← Back to Dataset</button> <button class="btn btn-modal" id="chart-builder-back-btn" @click="close()" title="Back to dataset details">← Back to Dataset</button>
</div> </div>
<div class="chart-builder-section"> <div class="chart-builder-section">
<div class="mark-type-row"> <div class="mark-type-row">
<label class="chart-builder-label">Mark Type*</label> <label class="chart-builder-label">Mark Type*</label>
<div class="mark-toggle-group"> <div class="mark-toggle-group">
<button class="btn btn-toggle small active" data-mark="bar" title="Bar chart">Bar</button> <button class="btn btn-toggle small" :class="{ 'active': markType === 'bar' }" @click="setMarkType('bar')" data-mark="bar" title="Bar chart">Bar</button>
<button class="btn btn-toggle small" data-mark="line" title="Line chart">Line</button> <button class="btn btn-toggle small" :class="{ 'active': markType === 'line' }" @click="setMarkType('line')" data-mark="line" title="Line chart">Line</button>
<button class="btn btn-toggle small" data-mark="point" title="Point chart">Point</button> <button class="btn btn-toggle small" :class="{ 'active': markType === 'point' }" @click="setMarkType('point')" data-mark="point" title="Point chart">Point</button>
<button class="btn btn-toggle small" data-mark="area" title="Area chart">Area</button> <button class="btn btn-toggle small" :class="{ 'active': markType === 'area' }" @click="setMarkType('area')" data-mark="area" title="Area chart">Area</button>
<button class="btn btn-toggle small" data-mark="circle" title="Circle chart">Circle</button> <button class="btn btn-toggle small" :class="{ 'active': markType === 'circle' }" @click="setMarkType('circle')" data-mark="circle" title="Circle chart">Circle</button>
</div> </div>
</div> </div>
</div> </div>
@@ -506,17 +506,17 @@
<div class="encoding-group"> <div class="encoding-group">
<div class="encoding-row"> <div class="encoding-row">
<label class="encoding-header">X Axis</label> <label class="encoding-header">X Axis</label>
<select id="encoding-x-field" class="input"> <select id="encoding-x-field" class="input" x-model="encodings.x.field" @change="setEncodingField('x', $event.target.value)">
<option value="">None</option> <option value="">None</option>
</select> </select>
</div> </div>
<div class="encoding-type"> <div class="encoding-type">
<label class="encoding-type-label">Type</label> <label class="encoding-type-label">Type</label>
<div class="type-toggle-group"> <div class="type-toggle-group">
<button class="btn btn-toggle small" data-encoding="x" data-type="quantitative" title="Quantitative">Q</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.x.type === 'quantitative' }" @click="setEncodingType('x', 'quantitative')" data-encoding="x" data-type="quantitative" title="Quantitative">Q</button>
<button class="btn btn-toggle small" data-encoding="x" data-type="ordinal" title="Ordinal">O</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.x.type === 'ordinal' }" @click="setEncodingType('x', 'ordinal')" data-encoding="x" data-type="ordinal" title="Ordinal">O</button>
<button class="btn btn-toggle small" data-encoding="x" data-type="nominal" title="Nominal">N</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.x.type === 'nominal' }" @click="setEncodingType('x', 'nominal')" data-encoding="x" data-type="nominal" title="Nominal">N</button>
<button class="btn btn-toggle small" data-encoding="x" data-type="temporal" title="Temporal">T</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.x.type === 'temporal' }" @click="setEncodingType('x', 'temporal')" data-encoding="x" data-type="temporal" title="Temporal">T</button>
</div> </div>
</div> </div>
</div> </div>
@@ -525,17 +525,17 @@
<div class="encoding-group"> <div class="encoding-group">
<div class="encoding-row"> <div class="encoding-row">
<label class="encoding-header">Y Axis</label> <label class="encoding-header">Y Axis</label>
<select id="encoding-y-field" class="input"> <select id="encoding-y-field" class="input" x-model="encodings.y.field" @change="setEncodingField('y', $event.target.value)">
<option value="">None</option> <option value="">None</option>
</select> </select>
</div> </div>
<div class="encoding-type"> <div class="encoding-type">
<label class="encoding-type-label">Type</label> <label class="encoding-type-label">Type</label>
<div class="type-toggle-group"> <div class="type-toggle-group">
<button class="btn btn-toggle small" data-encoding="y" data-type="quantitative" title="Quantitative">Q</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.y.type === 'quantitative' }" @click="setEncodingType('y', 'quantitative')" data-encoding="y" data-type="quantitative" title="Quantitative">Q</button>
<button class="btn btn-toggle small" data-encoding="y" data-type="ordinal" title="Ordinal">O</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.y.type === 'ordinal' }" @click="setEncodingType('y', 'ordinal')" data-encoding="y" data-type="ordinal" title="Ordinal">O</button>
<button class="btn btn-toggle small" data-encoding="y" data-type="nominal" title="Nominal">N</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.y.type === 'nominal' }" @click="setEncodingType('y', 'nominal')" data-encoding="y" data-type="nominal" title="Nominal">N</button>
<button class="btn btn-toggle small" data-encoding="y" data-type="temporal" title="Temporal">T</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.y.type === 'temporal' }" @click="setEncodingType('y', 'temporal')" data-encoding="y" data-type="temporal" title="Temporal">T</button>
</div> </div>
</div> </div>
</div> </div>
@@ -544,17 +544,17 @@
<div class="encoding-group"> <div class="encoding-group">
<div class="encoding-row"> <div class="encoding-row">
<label class="encoding-header">Color</label> <label class="encoding-header">Color</label>
<select id="encoding-color-field" class="input"> <select id="encoding-color-field" class="input" x-model="encodings.color.field" @change="setEncodingField('color', $event.target.value)">
<option value="">None</option> <option value="">None</option>
</select> </select>
</div> </div>
<div class="encoding-type"> <div class="encoding-type">
<label class="encoding-type-label">Type</label> <label class="encoding-type-label">Type</label>
<div class="type-toggle-group"> <div class="type-toggle-group">
<button class="btn btn-toggle small" data-encoding="color" data-type="quantitative" title="Quantitative">Q</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.color.type === 'quantitative' }" @click="setEncodingType('color', 'quantitative')" data-encoding="color" data-type="quantitative" title="Quantitative">Q</button>
<button class="btn btn-toggle small" data-encoding="color" data-type="ordinal" title="Ordinal">O</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.color.type === 'ordinal' }" @click="setEncodingType('color', 'ordinal')" data-encoding="color" data-type="ordinal" title="Ordinal">O</button>
<button class="btn btn-toggle small" data-encoding="color" data-type="nominal" title="Nominal">N</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.color.type === 'nominal' }" @click="setEncodingType('color', 'nominal')" data-encoding="color" data-type="nominal" title="Nominal">N</button>
<button class="btn btn-toggle small" data-encoding="color" data-type="temporal" title="Temporal">T</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.color.type === 'temporal' }" @click="setEncodingType('color', 'temporal')" data-encoding="color" data-type="temporal" title="Temporal">T</button>
</div> </div>
</div> </div>
</div> </div>
@@ -563,17 +563,17 @@
<div class="encoding-group"> <div class="encoding-group">
<div class="encoding-row"> <div class="encoding-row">
<label class="encoding-header">Size</label> <label class="encoding-header">Size</label>
<select id="encoding-size-field" class="input"> <select id="encoding-size-field" class="input" x-model="encodings.size.field" @change="setEncodingField('size', $event.target.value)">
<option value="">None</option> <option value="">None</option>
</select> </select>
</div> </div>
<div class="encoding-type"> <div class="encoding-type">
<label class="encoding-type-label">Type</label> <label class="encoding-type-label">Type</label>
<div class="type-toggle-group"> <div class="type-toggle-group">
<button class="btn btn-toggle small" data-encoding="size" data-type="quantitative" title="Quantitative">Q</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.size.type === 'quantitative' }" @click="setEncodingType('size', 'quantitative')" data-encoding="size" data-type="quantitative" title="Quantitative">Q</button>
<button class="btn btn-toggle small" data-encoding="size" data-type="ordinal" title="Ordinal">O</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.size.type === 'ordinal' }" @click="setEncodingType('size', 'ordinal')" data-encoding="size" data-type="ordinal" title="Ordinal">O</button>
<button class="btn btn-toggle small" data-encoding="size" data-type="nominal" title="Nominal">N</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.size.type === 'nominal' }" @click="setEncodingType('size', 'nominal')" data-encoding="size" data-type="nominal" title="Nominal">N</button>
<button class="btn btn-toggle small" data-encoding="size" data-type="temporal" title="Temporal">T</button> <button class="btn btn-toggle small" :class="{ 'active': encodings.size.type === 'temporal' }" @click="setEncodingType('size', 'temporal')" data-encoding="size" data-type="temporal" title="Temporal">T</button>
</div> </div>
</div> </div>
</div> </div>
@@ -584,11 +584,11 @@
<div class="chart-dimensions-group"> <div class="chart-dimensions-group">
<div class="dimension-input-group"> <div class="dimension-input-group">
<label class="dimension-label">Width</label> <label class="dimension-label">Width</label>
<input type="number" id="chart-width" class="input small" placeholder="auto" min="1" /> <input type="number" id="chart-width" class="input small" x-model.number="width" @input="updatePreview()" placeholder="auto" min="1" />
</div> </div>
<div class="dimension-input-group"> <div class="dimension-input-group">
<label class="dimension-label">Height</label> <label class="dimension-label">Height</label>
<input type="number" id="chart-height" class="input small" placeholder="auto" min="1" /> <input type="number" id="chart-height" class="input small" x-model.number="height" @input="updatePreview()" placeholder="auto" min="1" />
</div> </div>
</div> </div>
</div> </div>
@@ -596,8 +596,8 @@
<div class="chart-builder-error" id="chart-builder-error"></div> <div class="chart-builder-error" id="chart-builder-error"></div>
<div class="chart-builder-actions"> <div class="chart-builder-actions">
<button class="btn btn-modal primary" id="chart-builder-create-btn" title="Create snippet from chart" disabled>Create Snippet</button> <button class="btn btn-modal primary" id="chart-builder-create-btn" @click="createSnippet()" :disabled="!isValid" title="Create snippet from chart">Create Snippet</button>
<button class="btn btn-modal" id="chart-builder-cancel-btn" title="Cancel and close">Cancel</button> <button class="btn btn-modal" id="chart-builder-cancel-btn" @click="close()" title="Cancel and close">Cancel</button>
</div> </div>
</div> </div>

View File

@@ -130,9 +130,6 @@ document.addEventListener('DOMContentLoaded', function () {
// Initialize auto-save functionality // Initialize auto-save functionality
initializeAutoSave(); initializeAutoSave();
// Initialize chart builder
initializeChartBuilder();
// Initialize URL state management AFTER editor is ready // Initialize URL state management AFTER editor is ready
initializeURLStateManagement(); initializeURLStateManagement();
}); });
@@ -204,7 +201,7 @@ document.addEventListener('DOMContentLoaded', function () {
// Edit dataset button // Edit dataset button
if (editDatasetBtn) { if (editDatasetBtn) {
editDatasetBtn.addEventListener('click', async function() { editDatasetBtn.addEventListener('click', async function () {
if (Alpine.store('datasets').currentDatasetId) { if (Alpine.store('datasets').currentDatasetId) {
await showEditDatasetForm(Alpine.store('datasets').currentDatasetId); await showEditDatasetForm(Alpine.store('datasets').currentDatasetId);
} }
@@ -276,14 +273,14 @@ document.addEventListener('DOMContentLoaded', function () {
const previewRawBtn = document.getElementById('preview-raw-btn'); const previewRawBtn = document.getElementById('preview-raw-btn');
const previewTableBtn = document.getElementById('preview-table-btn'); const previewTableBtn = document.getElementById('preview-table-btn');
if (previewRawBtn) { if (previewRawBtn) {
previewRawBtn.addEventListener('click', function() { previewRawBtn.addEventListener('click', function () {
if (Alpine.store('datasets').currentDatasetData) { if (Alpine.store('datasets').currentDatasetData) {
showRawPreview(Alpine.store('datasets').currentDatasetData); showRawPreview(Alpine.store('datasets').currentDatasetData);
} }
}); });
} }
if (previewTableBtn) { if (previewTableBtn) {
previewTableBtn.addEventListener('click', function() { previewTableBtn.addEventListener('click', function () {
if (Alpine.store('datasets').currentDatasetData) { if (Alpine.store('datasets').currentDatasetData) {
showTablePreview(Alpine.store('datasets').currentDatasetData); showTablePreview(Alpine.store('datasets').currentDatasetData);
} }
@@ -291,7 +288,7 @@ document.addEventListener('DOMContentLoaded', function () {
} }
// Global modal event delegation - handles close buttons and overlay clicks // Global modal event delegation - handles close buttons and overlay clicks
document.addEventListener('click', function(e) { document.addEventListener('click', function (e) {
// Handle modal close buttons (×) // Handle modal close buttons (×)
if (e.target.id && e.target.id.endsWith('-modal-close')) { if (e.target.id && e.target.id.endsWith('-modal-close')) {
const modalId = e.target.id.replace('-close', ''); const modalId = e.target.id.replace('-close', '');
@@ -399,11 +396,11 @@ function initializeURLStateManagement() {
// Keyboard shortcut action handlers (shared between Monaco and document) // Keyboard shortcut action handlers (shared between Monaco and document)
const KeyboardActions = { const KeyboardActions = {
createNewSnippet: function() { createNewSnippet: function () {
createNewSnippet(); createNewSnippet();
}, },
toggleDatasetManager: function() { toggleDatasetManager: function () {
const modal = document.getElementById('dataset-modal'); const modal = document.getElementById('dataset-modal');
if (modal && modal.style.display === 'flex') { if (modal && modal.style.display === 'flex') {
closeDatasetManager(); closeDatasetManager();
@@ -412,13 +409,13 @@ const KeyboardActions = {
} }
}, },
publishDraft: function() { publishDraft: function () {
if (Alpine.store('snippets').viewMode === 'draft' && Alpine.store('snippets').currentSnippetId) { if (Alpine.store('snippets').viewMode === 'draft' && Alpine.store('snippets').currentSnippetId) {
publishDraft(); publishDraft();
} }
}, },
toggleSettings: function() { toggleSettings: function () {
if (ModalManager.isOpen('settings-modal')) { if (ModalManager.isOpen('settings-modal')) {
closeSettingsModal(); closeSettingsModal();
} else { } else {
@@ -426,7 +423,7 @@ const KeyboardActions = {
} }
}, },
closeAnyModal: function() { closeAnyModal: function () {
// Try ModalManager first for standard modals // Try ModalManager first for standard modals
if (ModalManager.closeAny()) { if (ModalManager.closeAny()) {
return true; return true;
@@ -448,7 +445,7 @@ const KeyboardActions = {
// Keyboard shortcuts handler (document-level) // Keyboard shortcuts handler (document-level)
function initializeKeyboardShortcuts() { function initializeKeyboardShortcuts() {
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function (e) {
// Escape: Close any open modal // Escape: Close any open modal
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (KeyboardActions.closeAnyModal()) { if (KeyboardActions.closeAnyModal()) {

View File

@@ -1,10 +1,299 @@
// Chart Builder - Visual chart construction from datasets // Chart Builder - Visual chart construction from datasets
// Global state for chart builder /**
window.chartBuilderState = null; * 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,
// Timeout for debounced preview updates // Chart configuration
let previewUpdateTimeout = null; 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 !== '');
},
// Initialize component with dataset
async init(datasetId) {
try {
// Validate datasetId is provided
if (!datasetId || isNaN(datasetId)) {
console.warn('Chart builder init 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 = '<div class="chart-preview-placeholder">Configure at least one encoding to see preview</div>';
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 = `<div class="chart-preview-placeholder" style="color: #d32f2f;">Error: ${error.message || 'Failed to render chart'}</div>`;
}
},
// Create snippet from current chart configuration
async createSnippet() {
if (!this.isValid) return;
try {
// 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: this.spec,
draftSpec: null,
comment: `Chart built from dataset: ${this.datasetName}`,
tags: [],
datasetRefs: [this.datasetName],
meta: {}
};
// Save snippet
SnippetStorage.saveSnippet(snippet);
// Close modals
this.close();
const datasetModal = document.getElementById('dataset-modal');
if (datasetModal) datasetModal.style.display = 'none';
// Select and open the new snippet
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 = '<div class="chart-preview-placeholder">Configure chart to see preview</div>';
}
// Clear error
const errorDiv = document.getElementById('chart-builder-error');
if (errorDiv) errorDiv.textContent = '';
}
};
}
// Map column types to Vega-Lite types // Map column types to Vega-Lite types
function mapColumnTypeToVegaType(columnType) { function mapColumnTypeToVegaType(columnType) {
@@ -23,38 +312,26 @@ function setActiveToggle(buttons, activeButton) {
activeButton.classList.add('active'); activeButton.classList.add('active');
} }
// Open chart builder modal with dataset // Open chart builder modal with dataset
async function openChartBuilder(datasetId) { async function openChartBuilder(datasetId) {
try { try {
// Fetch dataset from IndexedDB // Show modal first
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'); const modal = document.getElementById('chart-builder-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
// Get Alpine component instance and initialize it
const chartBuilderView = document.getElementById('chart-builder-view');
if (chartBuilderView && chartBuilderView._x_dataStack) {
const component = chartBuilderView._x_dataStack[0];
const success = await component.init(datasetId);
if (!success) {
modal.style.display = 'none';
return;
}
}
// Update URL to reflect chart builder state // Update URL to reflect chart builder state
URLState.update({ URLState.update({
view: 'datasets', view: 'datasets',
@@ -62,18 +339,15 @@ async function openChartBuilder(datasetId) {
action: 'build' action: 'build'
}); });
// Initial preview update (with a small delay to ensure DOM is ready)
setTimeout(() => {
updateChartBuilderPreview();
}, 50);
} catch (error) { } catch (error) {
console.error('Error opening chart builder:', error); console.error('Error opening chart builder:', error);
showToast('Error opening chart builder', 'error'); Toast.error('Error opening chart builder');
} }
} }
// Populate field dropdowns with dataset columns
// Populate field dropdowns with dataset columns (utility function)
function populateFieldDropdowns(dataset) { function populateFieldDropdowns(dataset) {
const encodings = ['x', 'y', 'color', 'size']; const encodings = ['x', 'y', 'color', 'size'];
const columns = dataset.columns || []; const columns = dataset.columns || [];
@@ -95,363 +369,11 @@ function populateFieldDropdowns(dataset) {
}); });
} }
// Auto-select smart defaults based on column types // closeChartBuilder - now calls Alpine component's close() method
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 = '<div class="chart-preview-placeholder">Configure at least one encoding to see preview</div>';
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 = `<div class="chart-preview-placeholder" style="color: #d32f2f;">Error: ${error.message || 'Failed to render chart'}</div>`;
// 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() { function closeChartBuilder() {
const modal = document.getElementById('chart-builder-modal'); const chartBuilderView = document.getElementById('chart-builder-view');
modal.style.display = 'none'; if (chartBuilderView && chartBuilderView._x_dataStack) {
const component = chartBuilderView._x_dataStack[0];
// Update URL - go back to dataset view component.close();
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 = '<div class="chart-preview-placeholder">Configure chart to see preview</div>';
}
// 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();
});
} }
} }