Files
astrolabe/index.html
2025-01-19 00:45:12 +02:00

452 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vega-Lite Editor Integration</title>
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsonc-parser@3.2.0/lib/umd/main.js"></script>
<style>
#vis {
width: 100%;
height: 100%;
padding: 1rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
display: grid;
grid-template-columns: var(--snippet-width, 1fr) 5px var(--editor-width, 1fr) 5px var(--preview-width, 2fr);
height: 100vh;
background-color: #e0e0e0;
}
.panel {
background: white;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 1rem;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f5f5;
}
.resize-handle {
background-color: #e0e0e0;
cursor: col-resize;
transition: background-color 0.2s;
}
.resize-handle:hover,
.resize-handle.active {
background-color: #2196F3;
}
.button {
padding: 0.5rem 1rem;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.button:hover {
background: #45a049;
}
.button:disabled {
background: #cccccc;
cursor: not-allowed;
}
.snippet-list {
padding: 1rem;
flex-grow: 1;
}
.snippet-item {
padding: 0.75rem;
margin: 0.5rem 0;
background: #f5f5f5;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.snippet-item:hover {
background: #eeeeee;
}
.snippet-item.active {
background: #e3f2fd;
border-left: 4px solid #2196F3;
}
#monaco-editor {
height: 100%;
flex-grow: 1;
}
.preview-panel>*:not(.panel-header) {
padding: 1rem;
}
</style>
</head>
<body>
<div class="container">
<div class="panel">
<div class="panel-header">
<h2>Snippets</h2>
<button class="button" id="new-snippet">New Snippet</button>
</div>
<div class="snippet-list" id="snippet-list">
<!-- Snippets will be populated here -->
</div>
</div>
<div class="resize-handle"></div>
<div class="panel">
<div class="panel-header">
<h2>Editor</h2>
<button class="button" id="save-snippet" disabled>Save Changes</button>
</div>
<div id="monaco-editor"></div>
</div>
<div class="resize-handle"></div>
<div class="panel preview-panel">
<div class="panel-header">
<h2>Preview</h2>
</div>
<div id="vis"></div>
</div>
</div>
<script>
// Default snippets
const defaultSnippets = [
{
id: 'simple-bar',
name: 'Simple Bar Chart',
content: {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"description": "A simple bar chart with embedded data.",
"data": {
"values": [
{ "category": "A", "value": 28 },
{ "category": "B", "value": 55 },
{ "category": "C", "value": 43 }
]
},
"mark": "bar",
"encoding": {
"x": { "field": "category", "type": "nominal" },
"y": { "field": "value", "type": "quantitative" }
}
}
},
{
id: 'scatter-plot',
name: 'Basic Scatter Plot',
content: {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"description": "A scatter plot example.",
"data": {
"values": [
{ "x": 1, "y": 28 }, { "x": 2, "y": 55 }, { "x": 3, "y": 43 }
]
},
"mark": "point",
"encoding": {
"x": { "field": "x", "type": "quantitative" },
"y": { "field": "y", "type": "quantitative" }
}
}
}
];
class PanelResizer {
constructor() {
this.handleDragStart = this.handleDragStart.bind(this);
this.handleDrag = this.handleDrag.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this);
this.loadLayout();
this.initializeResizeHandles();
}
loadLayout() {
const stored = localStorage.getItem('panelLayout');
if (stored) {
const layout = JSON.parse(stored);
document.documentElement.style.setProperty('--snippet-width', layout.snippetWidth);
document.documentElement.style.setProperty('--editor-width', layout.editorWidth);
document.documentElement.style.setProperty('--preview-width', layout.previewWidth);
}
}
saveLayout() {
const layout = {
snippetWidth: document.documentElement.style.getPropertyValue('--snippet-width'),
editorWidth: document.documentElement.style.getPropertyValue('--editor-width'),
previewWidth: document.documentElement.style.getPropertyValue('--preview-width')
};
localStorage.setItem('panelLayout', JSON.stringify(layout));
}
initializeResizeHandles() {
const handles = document.querySelectorAll('.resize-handle');
handles.forEach((handle, index) => {
handle.addEventListener('mousedown', (e) => this.handleDragStart(e, index));
});
}
handleDragStart(e, handleIndex) {
this.activeHandle = handleIndex;
this.startX = e.clientX;
this.handle = e.target;
this.handle.classList.add('active');
// Get the panels adjacent to the handle
const panels = document.querySelectorAll('.panel');
this.leftPanel = panels[handleIndex];
this.rightPanel = panels[handleIndex + 1];
// Store initial widths
this.leftWidth = this.leftPanel.getBoundingClientRect().width;
this.rightWidth = this.rightPanel.getBoundingClientRect().width;
document.addEventListener('mousemove', this.handleDrag);
document.addEventListener('mouseup', this.handleDragEnd);
}
handleDrag(e) {
if (!this.handle) return;
const dx = e.clientX - this.startX;
const containerWidth = document.querySelector('.container').getBoundingClientRect().width;
// Calculate new widths as fractions
const newLeftWidth = `${(this.leftWidth + dx) / containerWidth}fr`;
const newRightWidth = `${(this.rightWidth - dx) / containerWidth}fr`;
// Apply new widths based on which handle is being dragged
if (this.activeHandle === 0) {
document.documentElement.style.setProperty('--snippet-width', newLeftWidth);
document.documentElement.style.setProperty('--editor-width', newRightWidth);
} else {
document.documentElement.style.setProperty('--editor-width', newLeftWidth);
document.documentElement.style.setProperty('--preview-width', newRightWidth);
}
}
handleDragEnd() {
if (!this.handle) return;
this.handle.classList.remove('active');
this.handle = null;
this.saveLayout();
document.removeEventListener('mousemove', this.handleDrag);
document.removeEventListener('mouseup', this.handleDragEnd);
// Trigger Monaco editor resize
if (window.editor) {
window.editor.layout();
}
}
}
// Snippet management
class SnippetManager {
constructor() {
this.currentSnippetId = null;
this.hasUnsavedChanges = false;
this.loadSnippets();
this.setupUI();
}
loadSnippets() {
// Try to load from localStorage
const stored = localStorage.getItem('vegaSnippets');
this.snippets = stored ? JSON.parse(stored) : defaultSnippets;
// Initialize localStorage if empty
if (!stored) {
this.saveToStorage();
}
}
saveToStorage() {
localStorage.setItem('vegaSnippets', JSON.stringify(this.snippets));
}
renderSnippetList() {
const container = document.getElementById('snippet-list');
container.innerHTML = '';
this.snippets.forEach(snippet => {
const div = document.createElement('div');
div.className = `snippet-item ${snippet.id === this.currentSnippetId ? 'active' : ''}`;
div.textContent = snippet.name;
div.onclick = () => this.loadSnippet(snippet.id);
container.appendChild(div);
});
}
loadSnippet(id) {
if (this.hasUnsavedChanges) {
if (!confirm('You have unsaved changes. Do you want to discard them?')) {
return;
}
}
const snippet = this.snippets.find(s => s.id === id);
if (snippet) {
this.currentSnippetId = id;
this.editor.setValue(JSON.stringify(snippet.content, null, 2));
this.hasUnsavedChanges = false;
this.updateSaveButton();
this.renderSnippetList();
}
}
createNewSnippet() {
const name = prompt('Enter snippet name:', 'New Snippet');
if (!name) return;
const id = 'snippet-' + Date.now();
const newSnippet = {
id,
name,
content: {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"description": "New visualization",
"mark": "bar"
}
};
this.snippets.push(newSnippet);
this.saveToStorage();
this.loadSnippet(id);
}
saveCurrentSnippet() {
if (!this.currentSnippetId) return;
try {
const content = JSON.parse(this.editor.getValue());
const snippetIndex = this.snippets.findIndex(s => s.id === this.currentSnippetId);
if (snippetIndex !== -1) {
this.snippets[snippetIndex].content = content;
this.saveToStorage();
this.hasUnsavedChanges = false;
this.updateSaveButton();
}
} catch (e) {
alert('Invalid JSON in editor');
}
}
updateSaveButton() {
const saveButton = document.getElementById('save-snippet');
saveButton.disabled = !this.hasUnsavedChanges;
}
setupUI() {
// New snippet button
document.getElementById('new-snippet').onclick = () => this.createNewSnippet();
// Save button
document.getElementById('save-snippet').onclick = () => this.saveCurrentSnippet();
// Initial render
this.renderSnippetList();
}
setEditor(editor) {
this.editor = editor;
// Setup change tracking and visualization update
let timeoutId = null;
editor.onDidChangeModelContent(() => {
this.hasUnsavedChanges = true;
this.updateSaveButton();
// Debounce visualization updates
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
try {
const value = editor.getValue();
this.updateVisualization(value);
} catch (e) {
console.error('Invalid JSON:', e);
}
}, 300);
});
// Load first snippet if available
if (this.snippets.length > 0) {
this.loadSnippet(this.snippets[0].id);
}
}
async updateVisualization(spec) {
try {
const parsedSpec = typeof spec === 'string' ? JSON.parse(spec) : spec;
await vegaEmbed('#vis', parsedSpec, {
actions: true, // This adds the export/view source buttons
theme: 'light'
});
} catch (err) {
console.error('Error rendering visualization:', err);
// Optionally show error in the preview panel
document.getElementById('vis').innerHTML =
`<div style="color: red; padding: 1rem;">Error rendering visualization: ${err.message}</div>`;
}
}
}
// Initialize Monaco
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs' } });
const snippetManager = new SnippetManager();
require(['vs/editor/editor.main'], async function () {
// Create editor instance
const editor = monaco.editor.create(document.getElementById('monaco-editor'), {
language: 'json',
theme: 'vs-light',
wordWrap: 'on',
minimap: { enabled: false },
automaticLayout: true,
formatOnPaste: true,
formatOnType: true
});
const resizer = new PanelResizer();
window.editor = editor;
// Connect editor to snippet manager
snippetManager.setEditor(editor);
});
</script>
</body>
</html>