diff --git a/CLAUDE.md b/CLAUDE.md
index 1778add..04714dc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,13 +9,26 @@ Instructions for Claude Code when working on this project.
## Architecture
- **Frontend-only**: HTML/CSS/JavaScript with CDN dependencies (Monaco Editor, Vega-Embed)
-- **Storage**: localStorage with Phase 0 schema (id, name, created, modified, spec, draftSpec, comment, tags, datasetRefs, meta)
-- **Structure**: Three resizable panels (snippet library, Monaco editor, live preview)
-- **No build tools**: Open `index.html` directly in browser
+- **Storage**:
+ - **Snippets**: localStorage with Phase 0 schema (id, name, created, modified, spec, draftSpec, comment, tags, datasetRefs, meta)
+ - **Datasets**: IndexedDB (unlimited size, multi-format: JSON/CSV/TSV/TopoJSON, inline & URL sources)
+- **Structure**: Three resizable panels (snippet library, Monaco editor, live preview) + Dataset Manager modal
+- **No build tools**: Open `index.html` directly in browser (needs local server for IndexedDB)
## Current Status
-**Completed**: Phases 0-9 (All core functionality including import/export)
-**Next**: Phase 10 - Dataset Management
+**Completed**: Phases 0-10 (Core functionality + Dataset Management)
+**Next**: Phase 11 - Advanced Dataset Features (optional enhancements)
+
+### Key Features Implemented
+- ✅ Snippet management with draft/published workflow
+- ✅ Multi-field sorting and real-time search
+- ✅ Storage monitoring and import/export
+- ✅ **Dataset management with IndexedDB**
+ - Multi-format support (JSON, CSV, TSV, TopoJSON)
+ - Multi-source support (inline data, URL references)
+ - Automatic metadata calculation and URL fetching
+ - Dataset reference resolution in Vega-Lite specs
+ - Modal UI with button-group selectors
See `docs/dev-plan.md` for complete roadmap and technical details.
diff --git a/docs/dev-plan.md b/docs/dev-plan.md
index b8dc88c..9f431d5 100644
--- a/docs/dev-plan.md
+++ b/docs/dev-plan.md
@@ -189,31 +189,79 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
---
-### **Phase 10: Dataset Management - Part 1**
-**Goal**: Separate dataset storage infrastructure
+### **Phase 10: Dataset Management** ✅ **COMPLETE**
+**Goal**: Separate dataset storage with multiple data sources and formats
-- [ ] Implement dataset storage schema from Phase 0
-- [ ] Create dataset CRUD operations
-- [ ] Add dataset library panel/modal
-- [ ] List all stored datasets with metadata
-- [ ] Add/delete/rename datasets
-- [ ] Display dataset size and row counts
+**Deliverables**:
+- IndexedDB-based dataset storage (separate from snippets, no 5MB localStorage limit)
+- Full CRUD operations for datasets (create, read, update, delete)
+- Modal-based Dataset Manager UI (accessible via header link and 📁 toggle button)
+- Support for multiple data sources:
+ - **Inline data**: JSON, CSV, TSV, TopoJSON stored directly
+ - **URL data**: Remote data sources with format specification
+- Automatic metadata calculation:
+ - Row count, column count, column names
+ - Data size in bytes
+ - Automatic URL fetching on dataset creation
+- Refresh metadata button for URL datasets (🔄)
+- Dataset list with informative metadata display
+- Dataset details panel with:
+ - Editable name and comment
+ - Statistics display (rows, columns, size)
+ - Data preview (first 5 rows or URL info)
+ - Copy reference button (copies `"data": {"name": "dataset-name"}`)
+ - Delete button with confirmation
+- Auto-resolve dataset references in Vega-Lite specs during rendering
+- Format-aware rendering:
+ - JSON: `{ values: data }`
+ - CSV/TSV: `{ values: data, format: { type: 'csv'/'tsv' } }`
+ - TopoJSON: `{ values: data, format: { type: 'topojson' } }`
+ - URL: `{ url: "...", format: { type: '...' } }`
+- Button-group UI for source/format selection (matches editor style)
+- Graceful error handling for CORS and network failures
+- Modal scrolling support for small viewports
-**Deliverable**: Basic dataset storage separate from snippets
+**Dataset Schema**:
+```javascript
+{
+ id: number,
+ name: string,
+ created: ISO string,
+ modified: ISO string,
+ data: array/string/object, // Inline data or URL string
+ format: 'json'|'csv'|'tsv'|'topojson',
+ source: 'inline'|'url',
+ comment: string,
+ rowCount: number|null,
+ columnCount: number|null,
+ columns: string[],
+ size: number|null // bytes
+}
+```
+
+**Technical Implementation**:
+- IndexedDB with keyPath 'id', indexes on 'name' (unique) and 'modified'
+- Async/Promise-based DatasetStorage API
+- Format-specific parsing for metadata calculation
+- Vega-Lite's native format parsers used for rendering
+- Metadata refresh fetches live data and updates statistics
+- Modal resizes with viewport (max-height: 90vh)
---
-### **Phase 11: Dataset Management - Part 2**
-**Goal**: Reference datasets from specs
+### **Phase 11: Advanced Dataset Features** _(Future)_
+**Goal**: Enhanced dataset workflows
- [ ] Detect inline data in Vega-Lite specs
- [ ] "Extract to dataset" feature for inline data
-- [ ] Replace inline data with dataset references
-- [ ] Auto-resolve dataset references when rendering
- [ ] Update snippet UI to show linked datasets
-- [ ] Handle missing dataset references gracefully
+- [ ] Dataset usage tracking (which snippets reference which datasets)
+- [ ] Import datasets from file upload
+- [ ] Export individual datasets
+- [ ] Dataset preview with table view
+- [ ] Column type detection and display
-**Deliverable**: Specs can reference shared datasets
+**Deliverable**: Advanced dataset management and discovery
---
@@ -280,8 +328,9 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
│ └── js/ # Modular JavaScript organization
│ ├── config.js # Global variables, settings, & sample data
│ ├── snippet-manager.js # Snippet storage, CRUD operations & localStorage wrapper
+│ ├── dataset-manager.js # Dataset storage, CRUD operations & IndexedDB wrapper
│ ├── panel-manager.js # Panel resize, toggle & memory system
-│ ├── editor.js # Monaco Editor initialization & Vega-Lite rendering
+│ ├── editor.js # Monaco Editor initialization, dataset resolution & Vega-Lite rendering
│ └── app.js # Application initialization & event handlers
└── docs/
├── dev-plan.md # This development roadmap
@@ -311,15 +360,15 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
## Current Status
-**Completed**: Phases 0-9 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring, import/export)
-**Next**: Phase 10 - Dataset Management
+**Completed**: Phases 0-10 (Storage, UI, editor, rendering, persistence, CRUD, organization, draft/published workflow, storage monitoring, import/export, dataset management)
+**Next**: Phase 11 - Advanced Dataset Features (optional enhancements)
**See**: `CLAUDE.md` for concise current state summary
---
## Implemented Features
-### Core Capabilities (Phases 0-9)
+### Core Capabilities (Phases 0-10)
- Three-panel resizable layout with memory and persistence
- Monaco Editor v0.47.0 with Vega-Lite v5 schema validation
- Live Vega-Lite rendering with debounced updates and error display
@@ -334,6 +383,15 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
- Storage monitoring with visual progress bar and warning states
- Export/Import functionality with format auto-detection
- Snippet size display (right-aligned, shown for ≥ 1 KB)
+- **Dataset Management (Phase 10)**:
+ - IndexedDB storage for datasets (unlimited size)
+ - Multi-format support: JSON, CSV, TSV, TopoJSON
+ - Multi-source support: Inline data and URL references
+ - Modal-based Dataset Manager with full CRUD
+ - Automatic metadata calculation and display
+ - URL metadata fetching and refresh
+ - Dataset reference resolution in Vega-Lite specs
+ - Button-group UI for source/format selection
- Retro Windows 2000 aesthetic throughout
### Technical Implementation
@@ -348,4 +406,8 @@ Astrolabe is a focused tool for managing, editing, and previewing Vega-Lite visu
- **Storage Calculation**: Blob API for accurate byte counting of snippet data
- **Flexbox Layout**: Scrollable snippet list with fixed metadata and storage monitor at bottom
- **Import/Export**: Format detection, field normalization, ID conflict resolution, additive merging
-- **Size Display**: Per-snippet size calculation with conditional rendering (≥ 1 KB threshold)
\ No newline at end of file
+- **Size Display**: Per-snippet size calculation with conditional rendering (≥ 1 KB threshold)
+- **Dataset Storage**: IndexedDB with async/Promise-based API, unique name constraint
+- **Dataset Resolution**: Async spec transformation before rendering, format-aware data injection
+- **URL Metadata**: Fetch on creation with graceful CORS error handling
+- **Modal UI**: Flexbox with overflow:auto, max-height responsive to viewport
\ No newline at end of file
diff --git a/index.html b/index.html
index b6ff1ed..d783e27 100644
--- a/index.html
+++ b/index.html
@@ -21,6 +21,7 @@
@@ -38,6 +39,9 @@
+
@@ -144,8 +148,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rows:
+ 0
+
+
+ Columns:
+ 0
+
+
+ Size:
+ 0 B
+
+
+
+
+
+
+ Created:
+ -
+
+
+ Modified:
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/js/app.js b/src/js/app.js
index d8e17de..2c13f7c 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -117,6 +117,70 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
+ // Dataset Manager
+ const datasetsLink = document.getElementById('datasets-link');
+ const toggleDatasetsBtn = document.getElementById('toggle-datasets');
+ const datasetModal = document.getElementById('dataset-modal');
+ const datasetModalClose = document.getElementById('dataset-modal-close');
+ const newDatasetBtn = document.getElementById('new-dataset-btn');
+ const cancelDatasetBtn = document.getElementById('cancel-dataset-btn');
+ const saveDatasetBtn = document.getElementById('save-dataset-btn');
+ const deleteDatasetBtn = document.getElementById('delete-dataset-btn');
+ const copyReferenceBtn = document.getElementById('copy-reference-btn');
+
+ // Open dataset manager
+ if (datasetsLink) {
+ datasetsLink.addEventListener('click', openDatasetManager);
+ }
+ if (toggleDatasetsBtn) {
+ toggleDatasetsBtn.addEventListener('click', openDatasetManager);
+ }
+
+ // Close dataset manager
+ if (datasetModalClose) {
+ datasetModalClose.addEventListener('click', closeDatasetManager);
+ }
+
+ // Close on overlay click
+ if (datasetModal) {
+ datasetModal.addEventListener('click', function (e) {
+ if (e.target === datasetModal) {
+ closeDatasetManager();
+ }
+ });
+ }
+
+ // New dataset button
+ if (newDatasetBtn) {
+ newDatasetBtn.addEventListener('click', showNewDatasetForm);
+ }
+
+ // Cancel dataset button
+ if (cancelDatasetBtn) {
+ cancelDatasetBtn.addEventListener('click', hideNewDatasetForm);
+ }
+
+ // Save dataset button
+ if (saveDatasetBtn) {
+ saveDatasetBtn.addEventListener('click', saveNewDataset);
+ }
+
+ // Delete dataset button
+ if (deleteDatasetBtn) {
+ deleteDatasetBtn.addEventListener('click', deleteCurrentDataset);
+ }
+
+ // Copy reference button
+ if (copyReferenceBtn) {
+ copyReferenceBtn.addEventListener('click', copyDatasetReference);
+ }
+
+ // Refresh metadata button
+ const refreshMetadataBtn = document.getElementById('refresh-metadata-btn');
+ if (refreshMetadataBtn) {
+ refreshMetadataBtn.addEventListener('click', refreshDatasetMetadata);
+ }
+
// View mode toggle buttons
document.getElementById('view-draft').addEventListener('click', () => {
switchViewMode('draft');
diff --git a/src/js/dataset-manager.js b/src/js/dataset-manager.js
new file mode 100644
index 0000000..77e89ba
--- /dev/null
+++ b/src/js/dataset-manager.js
@@ -0,0 +1,636 @@
+// Dataset management with IndexedDB
+
+const DB_NAME = 'astrolabe-datasets';
+const DB_VERSION = 1;
+const STORE_NAME = 'datasets';
+
+let db = null;
+
+// Initialize IndexedDB
+function initializeDatasetDB() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ db = request.result;
+ resolve(db);
+ };
+
+ request.onupgradeneeded = (event) => {
+ const db = event.target.result;
+
+ // Create object store if it doesn't exist
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
+ objectStore.createIndex('name', 'name', { unique: true });
+ objectStore.createIndex('modified', 'modified', { unique: false });
+ }
+ };
+ });
+}
+
+// Generate unique ID
+function generateDatasetId() {
+ return Date.now() + Math.random() * 1000;
+}
+
+// Calculate dataset statistics
+function calculateDatasetStats(data, format, source) {
+ let rowCount = 0;
+ let columnCount = 0;
+ let columns = [];
+ let size = 0;
+
+ // For URL sources, we can't calculate stats without fetching
+ if (source === 'url') {
+ return { rowCount: null, columnCount: null, columns: [], size: null };
+ }
+
+ if (format === 'json' || format === 'topojson') {
+ if (!Array.isArray(data) || data.length === 0) {
+ return { rowCount: 0, columnCount: 0, columns: [], size: 0 };
+ }
+ rowCount = data.length;
+ const firstRow = data[0];
+ columns = typeof firstRow === 'object' ? Object.keys(firstRow) : [];
+ columnCount = columns.length;
+ size = new Blob([JSON.stringify(data)]).size;
+ } else if (format === 'csv' || format === 'tsv') {
+ // For CSV/TSV, data is stored as raw text
+ const lines = data.trim().split('\n');
+ rowCount = Math.max(0, lines.length - 1); // Subtract header row
+ if (lines.length > 0) {
+ const separator = format === 'csv' ? ',' : '\t';
+ columns = lines[0].split(separator).map(h => h.trim().replace(/^"|"$/g, ''));
+ columnCount = columns.length;
+ }
+ size = new Blob([data]).size;
+ }
+
+ return { rowCount, columnCount, columns, size };
+}
+
+// Dataset Storage API
+const DatasetStorage = {
+ // Initialize database
+ async init() {
+ if (!db) {
+ await initializeDatasetDB();
+ }
+ return db;
+ },
+
+ // Create new dataset
+ async createDataset(name, data, format, source, comment = '') {
+ await this.init();
+
+ const now = new Date().toISOString();
+ const stats = calculateDatasetStats(data, format, source);
+
+ const dataset = {
+ id: generateDatasetId(),
+ name: name.trim(),
+ created: now,
+ modified: now,
+ data: data, // For inline: actual data, for URL: the URL string
+ format: format, // 'json', 'csv', 'tsv', or 'topojson'
+ source: source, // 'inline' or 'url'
+ comment: comment.trim(),
+ ...stats
+ };
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([STORE_NAME], 'readwrite');
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.add(dataset);
+
+ request.onsuccess = () => resolve(dataset);
+ request.onerror = () => reject(request.error);
+ });
+ },
+
+ // Get all datasets
+ async listDatasets() {
+ await this.init();
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([STORE_NAME], 'readonly');
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.getAll();
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+ },
+
+ // Get single dataset by ID
+ async getDataset(id) {
+ await this.init();
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([STORE_NAME], 'readonly');
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.get(id);
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+ },
+
+ // Get dataset by name
+ async getDatasetByName(name) {
+ await this.init();
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([STORE_NAME], 'readonly');
+ const store = transaction.objectStore(STORE_NAME);
+ const index = store.index('name');
+ const request = index.get(name);
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+ },
+
+ // Update dataset
+ async updateDataset(id, updates) {
+ await this.init();
+
+ const dataset = await this.getDataset(id);
+ if (!dataset) {
+ throw new Error('Dataset not found');
+ }
+
+ // Update fields
+ if (updates.name !== undefined) dataset.name = updates.name.trim();
+ if (updates.data !== undefined) {
+ dataset.data = updates.data;
+ if (updates.format !== undefined) dataset.format = updates.format;
+ if (updates.source !== undefined) dataset.source = updates.source;
+
+ // If metadata fields are explicitly provided, use them directly
+ if (updates.rowCount !== undefined) dataset.rowCount = updates.rowCount;
+ if (updates.columnCount !== undefined) dataset.columnCount = updates.columnCount;
+ if (updates.columns !== undefined) dataset.columns = updates.columns;
+ if (updates.size !== undefined) dataset.size = updates.size;
+
+ // Otherwise, calculate stats (for inline data)
+ if (updates.rowCount === undefined && updates.columnCount === undefined) {
+ const stats = calculateDatasetStats(updates.data, dataset.format, dataset.source);
+ Object.assign(dataset, stats);
+ }
+ }
+ if (updates.comment !== undefined) dataset.comment = updates.comment.trim();
+
+ dataset.modified = new Date().toISOString();
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([STORE_NAME], 'readwrite');
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.put(dataset);
+
+ request.onsuccess = () => resolve(dataset);
+ request.onerror = () => reject(request.error);
+ });
+ },
+
+ // Delete dataset
+ async deleteDataset(id) {
+ await this.init();
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([STORE_NAME], 'readwrite');
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.delete(id);
+
+ request.onsuccess = () => resolve(true);
+ request.onerror = () => reject(request.error);
+ });
+ },
+
+ // Check if dataset name exists
+ async nameExists(name, excludeId = null) {
+ const datasets = await this.listDatasets();
+ return datasets.some(d => d.name === name && d.id !== excludeId);
+ }
+};
+
+// Format bytes for display
+function formatDatasetSize(bytes) {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+}
+
+// Fetch URL data and calculate metadata
+async function fetchURLMetadata(url, format) {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const contentLength = response.headers.get('content-length');
+ const text = await response.text();
+
+ let rowCount = 0;
+ let columnCount = 0;
+ let columns = [];
+ let size = contentLength ? parseInt(contentLength) : new Blob([text]).size;
+
+ // Parse based on format
+ if (format === 'json') {
+ const data = JSON.parse(text);
+ if (Array.isArray(data)) {
+ rowCount = data.length;
+ if (data.length > 0 && typeof data[0] === 'object') {
+ columns = Object.keys(data[0]);
+ columnCount = columns.length;
+ }
+ }
+ } else if (format === 'csv' || format === 'tsv') {
+ const lines = text.trim().split('\n');
+ rowCount = Math.max(0, lines.length - 1); // Subtract header
+ if (lines.length > 0) {
+ const separator = format === 'csv' ? ',' : '\t';
+ columns = lines[0].split(separator).map(h => h.trim().replace(/^"|"$/g, ''));
+ columnCount = columns.length;
+ }
+ } else if (format === 'topojson') {
+ // TopoJSON structure is complex, just note it exists
+ rowCount = null;
+ columnCount = null;
+ }
+
+ return { rowCount, columnCount, columns, size };
+ } catch (error) {
+ throw new Error(`Failed to fetch URL metadata: ${error.message}`);
+ }
+}
+
+// Render dataset list in modal
+async function renderDatasetList() {
+ const datasets = await DatasetStorage.listDatasets();
+ const listContainer = document.getElementById('dataset-list');
+
+ if (datasets.length === 0) {
+ listContainer.innerHTML = 'No datasets yet. Click "New Dataset" to create one.
';
+ return;
+ }
+
+ // Sort by modified date (most recent first)
+ datasets.sort((a, b) => new Date(b.modified) - new Date(a.modified));
+
+ const html = datasets.map(dataset => {
+ let metaText;
+ if (dataset.source === 'url') {
+ // Show metadata if available, otherwise just URL and format
+ if (dataset.rowCount !== null && dataset.size !== null) {
+ metaText = `URL • ${dataset.rowCount} rows • ${dataset.format.toUpperCase()} • ${formatDatasetSize(dataset.size)}`;
+ } else {
+ metaText = `URL • ${dataset.format.toUpperCase()}`;
+ }
+ } else {
+ metaText = `${dataset.rowCount} rows • ${dataset.format.toUpperCase()} • ${formatDatasetSize(dataset.size)}`;
+ }
+
+ return `
+
+
+
${dataset.name}
+
${metaText}
+
+
+ `;
+ }).join('');
+
+ listContainer.innerHTML = html;
+
+ // Attach click handlers
+ document.querySelectorAll('.dataset-item').forEach(item => {
+ item.addEventListener('click', function() {
+ const datasetId = parseFloat(this.dataset.datasetId);
+ selectDataset(datasetId);
+ });
+ });
+}
+
+// Select a dataset and show details
+async function selectDataset(datasetId) {
+ const dataset = await DatasetStorage.getDataset(datasetId);
+ if (!dataset) return;
+
+ // Update selection state
+ document.querySelectorAll('.dataset-item').forEach(item => {
+ item.classList.remove('selected');
+ });
+ document.querySelector(`[data-dataset-id="${datasetId}"]`).classList.add('selected');
+
+ // Show details panel
+ const detailsPanel = document.getElementById('dataset-details');
+ detailsPanel.style.display = 'block';
+
+ // Show/hide refresh button for URL datasets
+ const refreshBtn = document.getElementById('refresh-metadata-btn');
+ if (dataset.source === 'url') {
+ refreshBtn.style.display = 'flex';
+ } else {
+ refreshBtn.style.display = 'none';
+ }
+
+ // Populate details
+ document.getElementById('dataset-detail-name').value = dataset.name;
+ document.getElementById('dataset-detail-comment').value = dataset.comment;
+ document.getElementById('dataset-detail-rows').textContent = dataset.rowCount !== null ? dataset.rowCount : 'N/A';
+ document.getElementById('dataset-detail-columns').textContent = dataset.columnCount !== null ? dataset.columnCount : 'N/A';
+ document.getElementById('dataset-detail-size').textContent = dataset.size !== null ? formatDatasetSize(dataset.size) : 'N/A';
+ document.getElementById('dataset-detail-created').textContent = new Date(dataset.created).toLocaleString();
+ document.getElementById('dataset-detail-modified').textContent = new Date(dataset.modified).toLocaleString();
+
+ // Show preview
+ let previewText;
+ if (dataset.source === 'url') {
+ previewText = `URL: ${dataset.data}\nFormat: ${dataset.format.toUpperCase()}`;
+ } else if (dataset.format === 'json' || dataset.format === 'topojson') {
+ const previewData = Array.isArray(dataset.data) ? dataset.data.slice(0, 5) : dataset.data;
+ previewText = JSON.stringify(previewData, null, 2);
+ } else if (dataset.format === 'csv' || dataset.format === 'tsv') {
+ const lines = dataset.data.split('\n');
+ previewText = lines.slice(0, 6).join('\n'); // Header + 5 rows
+ }
+ document.getElementById('dataset-preview').textContent = previewText;
+
+ // Store current dataset ID
+ window.currentDatasetId = datasetId;
+}
+
+// Open dataset manager modal
+function openDatasetManager() {
+ const modal = document.getElementById('dataset-modal');
+ modal.style.display = 'flex';
+ renderDatasetList();
+}
+
+// Close dataset manager modal
+function closeDatasetManager() {
+ const modal = document.getElementById('dataset-modal');
+ modal.style.display = 'none';
+ window.currentDatasetId = null;
+}
+
+// Update format hint and placeholder
+function updateFormatHint(format) {
+ const hintEl = document.getElementById('dataset-format-hint');
+ const dataEl = document.getElementById('dataset-form-data');
+
+ if (format === 'json') {
+ hintEl.textContent = 'JSON array of objects: [{"col1": "value", "col2": 123}, ...]';
+ dataEl.placeholder = '[{"col1": "value", "col2": 123}, ...]';
+ } else if (format === 'csv') {
+ hintEl.textContent = 'CSV with header row: col1,col2\\nvalue1,123\\nvalue2,456';
+ dataEl.placeholder = 'col1,col2\nvalue1,123\nvalue2,456';
+ } else if (format === 'tsv') {
+ hintEl.textContent = 'TSV with header row: col1\\tcol2\\nvalue1\\t123\\nvalue2\\t456';
+ dataEl.placeholder = 'col1\tcol2\nvalue1\t123\nvalue2\t456';
+ } else if (format === 'topojson') {
+ hintEl.textContent = 'TopoJSON object: {"type": "Topology", "objects": {...}, "arcs": [...]}';
+ dataEl.placeholder = '{"type": "Topology", "objects": {...}}';
+ }
+}
+
+// Toggle between URL and inline data inputs
+function toggleDataSource(source) {
+ const urlGroup = document.getElementById('dataset-url-group');
+ const dataGroup = document.getElementById('dataset-data-group');
+
+ if (source === 'url') {
+ urlGroup.style.display = 'block';
+ dataGroup.style.display = 'none';
+ } else {
+ urlGroup.style.display = 'none';
+ dataGroup.style.display = 'block';
+ }
+}
+
+// Show new dataset form
+function showNewDatasetForm() {
+ document.getElementById('dataset-list-view').style.display = 'none';
+ document.getElementById('dataset-form-view').style.display = 'block';
+ document.getElementById('dataset-form-name').value = '';
+ document.getElementById('dataset-form-data').value = '';
+ document.getElementById('dataset-form-url').value = '';
+ document.getElementById('dataset-form-comment').value = '';
+ document.getElementById('dataset-form-error').textContent = '';
+
+ // Reset to inline data source and JSON format
+ document.querySelectorAll('[data-source]').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.source === 'inline');
+ });
+ document.querySelectorAll('[data-format]').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.format === 'json');
+ });
+ toggleDataSource('inline');
+ updateFormatHint('json');
+
+ // Add listeners if not already added
+ if (!window.datasetListenersAdded) {
+ // Source toggle button listeners
+ document.querySelectorAll('[data-source]').forEach(btn => {
+ btn.addEventListener('click', function () {
+ // Update active state
+ document.querySelectorAll('[data-source]').forEach(b => b.classList.remove('active'));
+ this.classList.add('active');
+ toggleDataSource(this.dataset.source);
+ });
+ });
+
+ // Format toggle button listeners
+ document.querySelectorAll('[data-format]').forEach(btn => {
+ btn.addEventListener('click', function () {
+ // Update active state
+ document.querySelectorAll('[data-format]').forEach(b => b.classList.remove('active'));
+ this.classList.add('active');
+ updateFormatHint(this.dataset.format);
+ });
+ });
+
+ window.datasetListenersAdded = true;
+ }
+}
+
+// Hide new dataset form
+function hideNewDatasetForm() {
+ document.getElementById('dataset-list-view').style.display = 'block';
+ document.getElementById('dataset-form-view').style.display = 'none';
+}
+
+// Save new dataset
+async function saveNewDataset() {
+ const name = document.getElementById('dataset-form-name').value.trim();
+ const source = document.querySelector('[data-source].active').dataset.source;
+ const format = document.querySelector('[data-format].active').dataset.format;
+ const comment = document.getElementById('dataset-form-comment').value.trim();
+ const errorEl = document.getElementById('dataset-form-error');
+
+ errorEl.textContent = '';
+
+ // Validation
+ if (!name) {
+ errorEl.textContent = 'Dataset name is required';
+ return;
+ }
+
+ let data;
+ let metadata = null;
+
+ if (source === 'url') {
+ const url = document.getElementById('dataset-form-url').value.trim();
+ if (!url) {
+ errorEl.textContent = 'URL is required';
+ return;
+ }
+ // Basic URL validation
+ try {
+ new URL(url);
+ } catch (error) {
+ errorEl.textContent = 'Invalid URL format';
+ return;
+ }
+
+ // Fetch metadata from URL
+ errorEl.textContent = 'Fetching data from URL...';
+ try {
+ metadata = await fetchURLMetadata(url, format);
+ errorEl.textContent = '';
+ } catch (error) {
+ errorEl.textContent = `Warning: ${error.message}. Dataset will be created without metadata.`;
+ // Continue anyway - URL might require CORS or auth
+ await new Promise(resolve => setTimeout(resolve, 2000)); // Show warning briefly
+ errorEl.textContent = '';
+ }
+
+ data = url; // Store the URL string
+ } else {
+ // Inline data
+ const dataText = document.getElementById('dataset-form-data').value.trim();
+ if (!dataText) {
+ errorEl.textContent = 'Data is required';
+ return;
+ }
+
+ // Basic validation of data format
+ try {
+ if (format === 'json' || format === 'topojson') {
+ const parsed = JSON.parse(dataText);
+ if (format === 'json' && !Array.isArray(parsed)) {
+ errorEl.textContent = 'JSON data must be an array of objects';
+ return;
+ }
+ if (format === 'json' && parsed.length === 0) {
+ errorEl.textContent = 'Data array cannot be empty';
+ return;
+ }
+ data = parsed; // Store as parsed JSON
+ } else if (format === 'csv' || format === 'tsv') {
+ const lines = dataText.trim().split('\n');
+ if (lines.length < 2) {
+ errorEl.textContent = `${format.toUpperCase()} must have at least a header row and one data row`;
+ return;
+ }
+ data = dataText; // Store as raw CSV/TSV string
+ }
+ } catch (error) {
+ errorEl.textContent = `Validation error: ${error.message}`;
+ return;
+ }
+ }
+
+ // Check if name already exists
+ if (await DatasetStorage.nameExists(name)) {
+ errorEl.textContent = 'A dataset with this name already exists';
+ return;
+ }
+
+ // Create dataset
+ try {
+ const dataset = await DatasetStorage.createDataset(name, data, format, source, comment);
+
+ // If we have metadata from URL fetch, update the dataset
+ if (metadata) {
+ await DatasetStorage.updateDataset(dataset.id, {
+ data: data,
+ ...metadata
+ });
+ }
+
+ hideNewDatasetForm();
+ await renderDatasetList();
+ } catch (error) {
+ errorEl.textContent = `Failed to save dataset: ${error.message}`;
+ }
+}
+
+// Delete current dataset
+async function deleteCurrentDataset() {
+ if (!window.currentDatasetId) return;
+
+ const dataset = await DatasetStorage.getDataset(window.currentDatasetId);
+ if (!dataset) return;
+
+ if (confirm(`Delete dataset "${dataset.name}"? This action cannot be undone.`)) {
+ await DatasetStorage.deleteDataset(window.currentDatasetId);
+ document.getElementById('dataset-details').style.display = 'none';
+ window.currentDatasetId = null;
+ await renderDatasetList();
+ }
+}
+
+// Copy dataset reference to clipboard
+function copyDatasetReference() {
+ if (!window.currentDatasetId) return;
+
+ DatasetStorage.getDataset(window.currentDatasetId).then(dataset => {
+ const reference = `"data": {"name": "${dataset.name}"}`;
+ navigator.clipboard.writeText(reference).then(() => {
+ alert('Dataset reference copied to clipboard!');
+ });
+ });
+}
+
+// Refresh metadata for URL dataset
+async function refreshDatasetMetadata() {
+ if (!window.currentDatasetId) return;
+
+ const dataset = await DatasetStorage.getDataset(window.currentDatasetId);
+ if (!dataset || dataset.source !== 'url') return;
+
+ const refreshBtn = document.getElementById('refresh-metadata-btn');
+ refreshBtn.disabled = true;
+ refreshBtn.textContent = '⏳';
+
+ try {
+ const metadata = await fetchURLMetadata(dataset.data, dataset.format);
+
+ // Update dataset with new metadata
+ await DatasetStorage.updateDataset(dataset.id, {
+ data: dataset.data,
+ ...metadata
+ });
+
+ // Refresh the display
+ await selectDataset(dataset.id);
+ await renderDatasetList();
+
+ // Brief success indicator
+ refreshBtn.textContent = '✓';
+ setTimeout(() => {
+ refreshBtn.textContent = '🔄';
+ refreshBtn.disabled = false;
+ }, 1000);
+ } catch (error) {
+ alert(`Failed to refresh metadata: ${error.message}`);
+ refreshBtn.textContent = '🔄';
+ refreshBtn.disabled = false;
+ }
+}
diff --git a/src/js/editor.js b/src/js/editor.js
index 2ea6129..a61b308 100644
--- a/src/js/editor.js
+++ b/src/js/editor.js
@@ -1,3 +1,76 @@
+// Resolve dataset references in a spec
+async function resolveDatasetReferences(spec) {
+ // If spec has data.name, look it up
+ if (spec.data && spec.data.name && typeof spec.data.name === 'string') {
+ const datasetName = spec.data.name;
+ const dataset = await DatasetStorage.getDatasetByName(datasetName);
+
+ if (dataset) {
+ // Replace data reference with actual data in the format Vega-Lite expects
+ if (dataset.source === 'url') {
+ // For URL sources, pass the URL and format
+ spec.data = {
+ url: dataset.data,
+ format: { type: dataset.format }
+ };
+ } else {
+ // For inline sources
+ if (dataset.format === 'json') {
+ spec.data = { values: dataset.data };
+ } else if (dataset.format === 'csv') {
+ spec.data = {
+ values: dataset.data,
+ format: { type: 'csv' }
+ };
+ } else if (dataset.format === 'tsv') {
+ spec.data = {
+ values: dataset.data,
+ format: { type: 'tsv' }
+ };
+ } else if (dataset.format === 'topojson') {
+ spec.data = {
+ values: dataset.data,
+ format: { type: 'topojson' }
+ };
+ }
+ }
+ } else {
+ throw new Error(`Dataset "${datasetName}" not found`);
+ }
+ }
+
+ // Recursively resolve in layers (for layered specs)
+ if (spec.layer && Array.isArray(spec.layer)) {
+ for (let layer of spec.layer) {
+ await resolveDatasetReferences(layer);
+ }
+ }
+
+ // Recursively resolve in concat/hconcat/vconcat
+ if (spec.concat && Array.isArray(spec.concat)) {
+ for (let view of spec.concat) {
+ await resolveDatasetReferences(view);
+ }
+ }
+ if (spec.hconcat && Array.isArray(spec.hconcat)) {
+ for (let view of spec.hconcat) {
+ await resolveDatasetReferences(view);
+ }
+ }
+ if (spec.vconcat && Array.isArray(spec.vconcat)) {
+ for (let view of spec.vconcat) {
+ await resolveDatasetReferences(view);
+ }
+ }
+
+ // Recursively resolve in facet
+ if (spec.spec) {
+ await resolveDatasetReferences(spec.spec);
+ }
+
+ return spec;
+}
+
// Render function that takes spec from editor
async function renderVisualization() {
const previewContainer = document.getElementById('vega-preview');
@@ -5,7 +78,10 @@ async function renderVisualization() {
try {
// Get current content from editor
const specText = editor.getValue();
- const spec = JSON.parse(specText);
+ let spec = JSON.parse(specText);
+
+ // Resolve dataset references
+ spec = await resolveDatasetReferences(spec);
// Render with Vega-Embed (use global variable)
await window.vegaEmbed('#vega-preview', spec, {
diff --git a/src/styles.css b/src/styles.css
index 3b6d33d..2a181b4 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -661,4 +661,408 @@ body {
.storage-fill.critical {
background: #ff0000;
+}
+
+/* Modal Styles */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ background: #c0c0c0;
+ border: 2px outset #c0c0c0;
+ width: 90%;
+ max-width: 900px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
+}
+
+.modal-header {
+ background: #000080;
+ color: #ffffff;
+ padding: 4px 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ height: 24px;
+ border-bottom: 2px solid #808080;
+}
+
+.modal-title {
+ font-size: 12px;
+ font-weight: bold;
+}
+
+.modal-close {
+ background: #c0c0c0;
+ border: 2px outset #c0c0c0;
+ color: #000000;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+ font-size: 14px;
+ font-family: 'MS Sans Serif', Tahoma, sans-serif;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.modal-close:hover {
+ background: #d4d0c8;
+}
+
+.modal-close:active {
+ border: 2px inset #c0c0c0;
+}
+
+.modal-body {
+ flex: 1;
+ overflow: auto;
+ background: #ffffff;
+ border: 2px inset #c0c0c0;
+ margin: 8px;
+ min-height: 0;
+}
+
+/* Dataset Views */
+.dataset-view {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+/* List View */
+.dataset-list-header {
+ padding: 8px;
+ background: #d4d0c8;
+ border-bottom: 2px solid #808080;
+}
+
+.dataset-container {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+.dataset-list {
+ width: 300px;
+ overflow-y: auto;
+ border-right: 2px solid #808080;
+ background: #ffffff;
+}
+
+.dataset-item {
+ padding: 8px;
+ border-bottom: 1px solid #d0d0d0;
+ cursor: pointer;
+ background: #ffffff;
+}
+
+.dataset-item:hover {
+ background: #6a9ad5;
+ color: #ffffff;
+}
+
+.dataset-item.selected {
+ background: #316ac5;
+ color: #ffffff;
+}
+
+.dataset-name {
+ font-size: 12px;
+ font-weight: bold;
+ margin-bottom: 2px;
+}
+
+.dataset-meta {
+ font-size: 10px;
+ color: #606060;
+}
+
+.dataset-item.selected .dataset-meta,
+.dataset-item:hover .dataset-meta {
+ color: inherit;
+ opacity: 0.9;
+}
+
+.dataset-empty {
+ padding: 32px;
+ text-align: center;
+ color: #808080;
+ font-style: italic;
+ font-size: 12px;
+}
+
+/* Dataset Details */
+.dataset-details {
+ flex: 1;
+ overflow-y: auto;
+ background: #ffffff;
+}
+
+.dataset-detail-section {
+ padding: 16px;
+}
+
+.dataset-detail-header {
+ font-size: 11px;
+ font-weight: bold;
+ margin-bottom: 6px;
+ margin-top: 12px;
+ color: #000000;
+}
+
+.dataset-detail-header:first-child {
+ margin-top: 0;
+}
+
+.dataset-detail-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 6px;
+ margin-top: 12px;
+}
+
+.dataset-refresh-btn {
+ background: #c0c0c0;
+ border: 2px outset #c0c0c0;
+ color: #000000;
+ width: 24px;
+ height: 24px;
+ cursor: pointer;
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.dataset-refresh-btn:hover {
+ background: #d4d0c8;
+}
+
+.dataset-refresh-btn:active {
+ border: 2px inset #c0c0c0;
+}
+
+.dataset-refresh-btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.dataset-input,
+.dataset-textarea {
+ width: 100%;
+ font-family: 'MS Sans Serif', Tahoma, sans-serif;
+ font-size: 11px;
+ border: 2px inset #c0c0c0;
+ padding: 4px;
+ background: #ffffff;
+}
+
+.dataset-textarea {
+ resize: vertical;
+}
+
+.dataset-stats {
+ background: #f0f0f0;
+ border: 1px inset #c0c0c0;
+ padding: 8px;
+ font-size: 10px;
+}
+
+.dataset-stat-item {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 4px;
+}
+
+.dataset-stat-item:last-child {
+ margin-bottom: 0;
+}
+
+.dataset-stat-label {
+ font-weight: bold;
+ color: #000000;
+}
+
+.dataset-preview-box {
+ background: #f0f0f0;
+ border: 2px inset #c0c0c0;
+ padding: 8px;
+ font-family: 'Courier New', monospace;
+ font-size: 10px;
+ overflow: auto;
+ max-height: 200px;
+ margin: 0;
+}
+
+.dataset-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+/* Dataset Form */
+.dataset-form {
+ padding: 16px;
+ height: 100%;
+ overflow-y: auto;
+}
+
+.dataset-form-header {
+ font-size: 14px;
+ font-weight: bold;
+ margin-bottom: 16px;
+ color: #000000;
+}
+
+.dataset-form-group {
+ margin-bottom: 12px;
+}
+
+.dataset-form-label {
+ display: block;
+ font-size: 11px;
+ font-weight: bold;
+ margin-bottom: 4px;
+ color: #000000;
+}
+
+.dataset-toggle-row {
+ display: flex;
+ gap: 24px;
+ padding: 8px;
+ background: #f0f0f0;
+ border: 1px inset #c0c0c0;
+ flex-wrap: wrap;
+}
+
+.dataset-toggle-section {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dataset-toggle-label {
+ font-size: 10px;
+ color: #000000;
+}
+
+.dataset-toggle-group {
+ display: flex;
+}
+
+.dataset-toggle-btn {
+ background: #c0c0c0;
+ border: 1px solid #808080;
+ color: #000000;
+ padding: 2px 8px;
+ cursor: pointer;
+ font-size: 10px;
+ font-family: 'MS Sans Serif', Tahoma, sans-serif;
+ height: 20px;
+ box-sizing: border-box;
+}
+
+.dataset-toggle-btn:not(:first-child) {
+ border-left: none;
+}
+
+.dataset-toggle-btn:hover:not(.active) {
+ background: #d4d0c8;
+}
+
+.dataset-toggle-btn:active {
+ background: #316ac5;
+ color: #ffffff;
+}
+
+.dataset-toggle-btn.active {
+ background: #316ac5;
+ color: #ffffff;
+ border-top: 1px solid #0a246a;
+ border-left: 1px solid #0a246a;
+ border-bottom: 1px solid #4a7ac5;
+ border-right: 1px solid #4a7ac5;
+}
+
+.dataset-toggle-btn.active:not(:first-child) {
+ border-left: 1px solid #0a246a;
+}
+
+.dataset-format-hint {
+ font-size: 10px;
+ color: #606060;
+ font-style: italic;
+ margin-bottom: 4px;
+ padding: 4px;
+ background: #fffacd;
+ border: 1px solid #e0e0a0;
+}
+
+.dataset-form-error {
+ color: #ff0000;
+ font-size: 11px;
+ margin-bottom: 12px;
+ min-height: 16px;
+}
+
+.dataset-form-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 16px;
+}
+
+/* Modal Buttons */
+.modal-btn {
+ background: #c0c0c0;
+ border: 2px outset #c0c0c0;
+ color: #000000;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 11px;
+ font-family: 'MS Sans Serif', Tahoma, sans-serif;
+}
+
+.modal-btn:hover {
+ background: #d4d0c8;
+}
+
+.modal-btn:active {
+ border: 2px inset #c0c0c0;
+}
+
+.modal-btn.primary {
+ background: #90ee90;
+}
+
+.modal-btn.primary:hover {
+ background: #a0ffa0;
+}
+
+.modal-btn.delete-btn {
+ background: #ff8080;
+ border: 2px outset #ff8080;
+}
+
+.modal-btn.delete-btn:hover {
+ background: #ff9999;
+}
+
+.modal-btn.delete-btn:active {
+ border: 2px inset #ff8080;
}
\ No newline at end of file