Files
astrolabe-nvc/index.html

1134 lines
70 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astrolabe - Vega-Lite Snippet Manager</title>
<link rel="stylesheet" href="src/styles.css">
<link rel="icon" type="image/svg+xml" href="src/favicon.svg" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#000080">
<!-- iOS PWA Support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Astrolabe">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<!-- Google Fonts - IBM Plex Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Monaco Editor -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs/loader.js"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<img src="src/favicon.svg" class="header-icon" alt="Astrolabe">
<span class="header-title">Astrolabe</span>
<span class="version-badge" id="app-version-badge">v0.3.0</span>
</div>
<div class="header-links">
<span class="header-link" id="import-link" title="Import snippets and datasets">Import</span>
<span class="header-link" id="export-link" title="Export all snippets and datasets">Export</span>
<span class="header-link" id="datasets-link" title="Open dataset manager (Cmd/Ctrl+K)">Datasets</span>
<span class="header-link" id="settings-link" title="Open settings (Cmd/Ctrl+,)">Settings 🛠️</span>
<span class="header-link" id="help-link" title="View keyboard shortcuts and help">About & Privacy</span>
<span class="header-link" id="donate-link" title="Support Astrolabe creators">Donate 🇺🇦</span>
<input type="file" id="import-file-input" accept=".json" style="display: none;" />
</div>
</div>
<div class="app-container">
<!-- Toggle Button Strip -->
<div class="toggle-strip" x-data>
<button class="btn btn-icon xlarge" id="toggle-snippet-panel"
:class="{ 'active': $store.panels.snippetVisible }" @click="togglePanel('snippet-panel')"
title="Toggle Snippets Panel">
📄
</button>
<button class="btn btn-icon xlarge" id="toggle-editor-panel"
:class="{ 'active': $store.panels.editorVisible }" @click="togglePanel('editor-panel')"
title="Toggle Editor Panel">
✏️
</button>
<button class="btn btn-icon xlarge" id="toggle-preview-panel"
:class="{ 'active': $store.panels.previewVisible }" @click="togglePanel('preview-panel')"
title="Toggle Preview Panel">
👁️
</button>
<button class="btn btn-icon xlarge" id="toggle-datasets" title="Datasets">
📁
</button>
</div>
<div class="main-panels">
<!-- Snippet Library Panel -->
<div class="panel snippet-panel" id="snippet-panel" x-data="snippetList()">
<div class="panel-header">
Snippets
</div>
<div class="sort-controls">
<span class="sort-label">Sort by:</span>
<button class="sort-btn" :class="{ 'active': sortBy === 'modified' }"
@click="toggleSort('modified')" title="Sort by last modified date">
<span class="sort-text">Modified</span>
<span class="sort-arrow"
x-text="sortBy === 'modified' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
<button class="sort-btn" :class="{ 'active': sortBy === 'created' }" @click="toggleSort('created')"
title="Sort by creation date">
<span class="sort-text">Created</span>
<span class="sort-arrow"
x-text="sortBy === 'created' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
<button class="sort-btn" :class="{ 'active': sortBy === 'name' }" @click="toggleSort('name')"
title="Sort alphabetically by name">
<span class="sort-text">Name</span>
<span class="sort-arrow" x-text="sortBy === 'name' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
<button class="sort-btn" :class="{ 'active': sortBy === 'size' }" @click="toggleSort('size')"
title="Sort by snippet size">
<span class="sort-text">Size</span>
<span class="sort-arrow" x-text="sortBy === 'size' && sortOrder === 'desc' ? '⬇' : '⬆'"></span>
</button>
</div>
<div class="search-controls">
<input type="text" id="snippet-search" x-model="searchQuery" placeholder="Search snippets..." />
<button class="btn btn-icon" @click="clearSearch()" title="Clear search">×</button>
</div>
<div class="panel-content">
<ul class="snippet-list" id="snippet-list">
<!-- Ghost card for creating new snippets -->
<li class="snippet-item ghost-card" id="new-snippet-card" @click="createNewSnippet()">
<div class="snippet-name">+ Create New Snippet</div>
<div class="snippet-date">Click to create</div>
</li>
<!-- Snippet items -->
<template x-for="snippet in filteredSnippets" :key="snippet.id">
<li class="snippet-item" :data-item-id="snippet.id"
:class="{ 'selected': $store.snippets.currentSnippetId === snippet.id }"
@click="selectSnippet(snippet.id)">
<div class="snippet-info">
<div class="snippet-name">
<span x-text="snippet.name"></span>
<span x-show="snippet.datasetRefs && snippet.datasetRefs.length > 0"
class="snippet-dataset-icon" title="Uses external dataset">📁</span>
</div>
<div class="snippet-date" x-text="formatDate(snippet)"></div>
</div>
<span x-show="getSize(snippet) >= 1" class="snippet-size"
x-text="getSize(snippet).toFixed(0) + ' KB'"></span>
<div class="snippet-status" :class="hasDraft(snippet) ? 'draft' : 'published'"></div>
</li>
</template>
</ul>
<div class="placeholder" x-show="filteredSnippets.length === 0"
x-text="searchQuery.trim() ? 'No snippets match your search' : 'No snippets found'">
Click to select a snippet
</div>
<div class="snippet-meta" id="snippet-meta" style="display: none;">
<div class="meta-header">Name</div>
<input type="text" id="snippet-name" class="input small" placeholder="Snippet name..."
x-model="snippetName" @input="saveMetaDebounced()" />
<div class="meta-header">Comment</div>
<textarea id="snippet-comment" class="input textarea medium" placeholder="Add a comment..."
rows="3" x-model="snippetComment" @input="saveMetaDebounced()"></textarea>
<div class="meta-info">
<div class="meta-info-item">
<span class="meta-info-label">Created:</span>
<span id="snippet-created"></span>
</div>
<div class="meta-info-item">
<span class="meta-info-label">Modified:</span>
<span id="snippet-modified"></span>
</div>
</div>
<div id="snippet-datasets-section" style="display: none;">
<div class="meta-header">Linked Datasets</div>
<div class="meta-info" id="snippet-datasets">
<!-- Dynamically populated by updateLinkedDatasets() -->
</div>
</div>
<div class="meta-actions">
<button class="btn btn-standard flex" id="duplicate-btn"
title="Create a copy of this snippet">Duplicate</button>
<button class="btn btn-standard flex danger" id="delete-btn"
title="Delete this snippet permanently">Delete</button>
</div>
</div>
<div class="storage-monitor" id="storage-monitor">
<div class="storage-info">
<span class="storage-label">Storage:</span>
<span class="storage-text" id="storage-text">0 KB / 5 MB</span>
</div>
<div class="storage-bar">
<div class="storage-fill" id="storage-fill"></div>
</div>
</div>
</div>
</div>
<!-- Resize Handle 1 -->
<div class="resize-handle" id="resize-handle-1"></div>
<!-- Editor Panel -->
<div class="panel editor-panel" id="editor-panel" x-data>
<div class="panel-header">
<span>Editor</span>
<div class="editor-controls">
<button class="btn btn-action" id="extract-btn" style="display: none; background: #87CEEB;"
title="Extract inline data to a reusable dataset">Extract to Dataset</button>
<button class="btn btn-action publish" id="publish-btn"
title="Publish draft changes (Cmd/Ctrl+S)">Publish</button>
<button class="btn btn-action revert" id="revert-btn"
title="Discard draft and revert to published version">Revert</button>
<span class="view-label">View:</span>
<div class="view-toggle-group">
<button class="btn btn-toggle" id="view-draft"
:class="{ 'active': $store.snippets.viewMode === 'draft' }"
@click="$store.snippets.viewMode = 'draft'; switchViewMode('draft')"
title="View and edit draft version">Draft</button>
<button class="btn btn-toggle" id="view-published"
:class="{ 'active': $store.snippets.viewMode === 'published' }"
@click="$store.snippets.viewMode = 'published'; switchViewMode('published')"
title="View published version (read-only if draft exists)">Published</button>
</div>
</div>
</div>
<div class="panel-content">
<div id="monaco-editor" style="height: 100%; width: 100%;"></div>
</div>
</div>
<!-- Resize Handle 2 -->
<div class="resize-handle" id="resize-handle-2"></div>
<!-- Preview Panel -->
<div class="panel preview-panel" id="preview-panel">
<div class="panel-header">
<span>Preview</span>
<div class="preview-controls" x-data>
<span class="view-label">Fit:</span>
<div class="view-toggle-group">
<button class="btn btn-toggle" :class="{ 'active': $store.preview.fitMode === 'default' }"
@click="$store.preview.fitMode = 'default'; setPreviewFitMode('default')"
id="preview-fit-default" title="Display at original spec dimensions">Original</button>
<button class="btn btn-toggle" :class="{ 'active': $store.preview.fitMode === 'width' }"
@click="$store.preview.fitMode = 'width'; setPreviewFitMode('width')"
id="preview-fit-width"
title="Scale to fit preview pane width (⚠️ for faceted specs, applies to each facet)">Width</button>
<button class="btn btn-toggle" :class="{ 'active': $store.preview.fitMode === 'full' }"
@click="$store.preview.fitMode = 'full'; setPreviewFitMode('full')"
id="preview-fit-full"
title="Scale to fit entire preview pane (⚠️ for faceted specs, applies to each facet)">Full</button>
</div>
</div>
</div>
<div class="panel-content" style="position: relative;">
<div id="vega-preview"
style="height: 100%; width: 100%; overflow: auto; display: flex; align-items: center; justify-content: center;">
</div>
<div id="preview-overlay" class="preview-overlay" style="display: none;"></div>
</div>
</div>
</div>
</div>
<!-- Dataset Manager Modal -->
<div id="dataset-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title">Dataset Manager</span>
<button class="btn btn-icon" id="dataset-modal-close" title="Close dataset manager (Escape)">×</button>
</div>
<div class="modal-body">
<!-- List View (default) -->
<div id="dataset-list-view" class="dataset-view" x-data="datasetList()">
<div class="dataset-list-header">
<button class="btn btn-modal primary" id="new-dataset-btn" title="Create a new dataset">New
Dataset</button>
<button class="btn btn-modal" id="import-dataset-btn"
title="Import dataset from file">Import</button>
<input type="file" id="import-dataset-file" accept=".json,.csv,.tsv,.txt"
style="display: none;" />
</div>
<div class="dataset-container">
<div class="dataset-list" id="dataset-list">
<!-- Dataset items rendered by Alpine.js -->
<template x-for="dataset in datasets" :key="dataset.id">
<div class="dataset-item" :data-item-id="dataset.id"
:class="{ 'selected': $store.datasets.currentDatasetId === dataset.id }"
@click="selectDataset(dataset.id)">
<div class="dataset-info">
<div class="dataset-name" x-text="dataset.name"></div>
<div class="dataset-meta" x-text="formatMeta(dataset)"></div>
</div>
<div class="dataset-usage-badge" x-show="getUsageCount(dataset) > 0"
:title="getUsageCount(dataset) + ' snippet' + (getUsageCount(dataset) !== 1 ? 's' : '') + ' using this dataset'"
x-text="'📄 ' + getUsageCount(dataset)">
</div>
</div>
</template>
<div class="dataset-empty" x-show="datasets.length === 0">
No datasets yet. Click "New Dataset" to create one.
</div>
</div>
<div class="dataset-details" id="dataset-details" style="display: none;">
<div class="dataset-detail-section">
<div class="dataset-actions">
<button class="btn btn-modal primary" id="edit-dataset-btn"
title="Edit this dataset contents">Edit Contents</button>
<button class="btn btn-modal primary" id="build-chart-btn"
title="Build a chart using this dataset">Build Chart</button>
<button class="btn btn-modal primary" id="new-snippet-btn"
title="Create a new snippet using this dataset">New Snippet</button>
<button class="btn btn-modal" id="export-dataset-btn"
title="Export this dataset to file">Export</button>
<button class="btn btn-modal" id="copy-reference-btn"
title="Copy dataset reference to clipboard">Copy Reference</button>
<button class="btn btn-modal danger" id="delete-dataset-btn"
title="Delete this dataset permanently">Delete</button>
</div>
<div class="dataset-detail-header">Name</div>
<input type="text" id="dataset-detail-name" class="input"
placeholder="Dataset name..." />
<div class="dataset-detail-header">Comment</div>
<textarea id="dataset-detail-comment" class="input textarea"
placeholder="Add a comment..." rows="3"></textarea>
<div class="dataset-detail-header-row">
<span class="dataset-detail-header">Overview</span>
<button class="btn btn-icon large" id="refresh-metadata-btn" style="display: none;"
title="Refresh metadata from URL">🔄</button>
</div>
<div class="dataset-overview-grid">
<div class="overview-section">
<div class="overview-section-title">Statistics</div>
<div class="stats-box">
<div class="stat-item">
<span class="stat-label">Rows:</span>
<span id="dataset-detail-rows">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Columns:</span>
<span id="dataset-detail-columns">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Size:</span>
<span id="dataset-detail-size">0 B</span>
</div>
</div>
</div>
<div class="overview-section" id="columns-section" style="display: none;">
<div class="overview-section-title">Columns</div>
<div class="columns-list" id="dataset-detail-columns-list">
<!-- Dynamically populated with column names and types -->
</div>
</div>
<div class="overview-section">
<div class="overview-section-title">Timestamps</div>
<div class="stats-box">
<div class="stat-item">
<span class="stat-label">Created:</span>
<span id="dataset-detail-created">-</span>
</div>
<div class="stat-item">
<span class="stat-label">Modified:</span>
<span id="dataset-detail-modified">-</span>
</div>
</div>
</div>
</div>
<div class="dataset-detail-header-row">
<span class="dataset-detail-header">Preview</span>
<div class="preview-toggle-group" id="preview-toggle-group" style="display: none;">
<button class="btn btn-toggle small active" id="preview-raw-btn"
title="Show raw data preview">Raw</button>
<button class="btn btn-toggle small" id="preview-table-btn"
title="Show data in table format with type detection">Table</button>
</div>
</div>
<pre id="dataset-preview" class="preview-box large"></pre>
<div id="dataset-preview-table" class="preview-table-container" style="display: none;">
</div>
<div id="dataset-snippets-section" style="display: none;">
<div class="dataset-detail-header">Linked Snippets</div>
<div class="stats-box" id="dataset-snippets">
<!-- Dynamically populated by updateLinkedSnippets() -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Form View (for creating new datasets) -->
<div id="dataset-form-view" class="dataset-view" style="display: none;">
<div class="dataset-form">
<div class="dataset-form-header">Create New Dataset</div>
<div class="dataset-form-group">
<label class="dataset-form-label">Name *</label>
<input type="text" id="dataset-form-name" class="input"
placeholder="Enter dataset name..." />
</div>
<div class="dataset-form-group">
<label class="dataset-form-label">Data or URL *</label>
<div class="dataset-format-hint">
Paste your data (JSON, CSV, or TSV) or a URL. Format will be detected automatically.
</div>
<textarea id="dataset-form-input" class="input textarea"
placeholder="Paste data or URL here..." rows="12"></textarea>
</div>
<!-- Detection Confirmation UI -->
<div id="dataset-detection-confirm" class="dataset-detection-confirm" style="display: none;">
<div class="detection-header">
<span class="detection-title">Detected:</span>
<div class="detection-badges">
<span class="detection-badge" id="detected-format">JSON</span>
<span class="detection-badge" id="detected-source">Inline</span>
<span class="detected-confidence high" id="detected-confidence">high
confidence</span>
</div>
</div>
<div class="detection-preview-label">Preview:</div>
<pre id="detected-preview" class="preview-box medium"></pre>
</div>
<div class="dataset-form-group">
<label class="dataset-form-label">Comment</label>
<textarea id="dataset-form-comment" class="input textarea"
placeholder="Optional description..." rows="3"></textarea>
</div>
<div class="dataset-form-error" id="dataset-form-error"></div>
<div class="dataset-form-actions">
<button class="btn btn-modal primary" id="save-dataset-btn" title="Save this dataset">Save
Dataset</button>
<button class="btn btn-modal" id="cancel-dataset-btn"
title="Cancel and return to dataset list">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Extract to Dataset Modal -->
<div id="extract-modal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px; height: auto; max-height: 80vh;">
<div class="modal-header">
<span class="modal-title">Extract to Dataset</span>
<button class="btn btn-icon" id="extract-modal-close" title="Close modal (Escape)">×</button>
</div>
<div class="modal-body">
<div style="padding: 16px;">
<div class="dataset-form-group">
<label class="dataset-form-label">Dataset Name *</label>
<input type="text" id="extract-dataset-name" class="input"
placeholder="Enter dataset name..." />
</div>
<div class="dataset-form-group">
<label class="dataset-form-label">Data Preview</label>
<pre id="extract-data-preview" class="preview-box large" style="max-height: 250px;"></pre>
</div>
<div class="dataset-form-error" id="extract-form-error"></div>
<div class="dataset-form-actions">
<button class="btn btn-modal primary" id="extract-create-btn"
title="Create dataset and update snippet reference">Create Dataset</button>
<button class="btn btn-modal" id="extract-cancel-btn" title="Cancel extraction">Cancel</button>
</div>
</div>
</div>
</div>
</div>
<!-- Chart Builder Modal -->
<div id="chart-builder-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title">Build Chart</span>
<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 class="modal-body">
<!-- Chart Builder View -->
<div id="chart-builder-view" class="chart-builder-view" x-data="chartBuilder()">
<div class="chart-builder-container">
<!-- Left Panel: Configuration -->
<div class="chart-builder-config">
<div class="chart-builder-header">
<button class="btn btn-modal" id="chart-builder-back-btn" @click="close()"
title="Back to dataset details">← Back to Dataset</button>
</div>
<div class="chart-builder-section">
<div class="mark-type-row">
<label class="chart-builder-label">Mark Type*</label>
<div class="mark-toggle-group">
<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" :class="{ 'active': markType === 'line' }"
@click="setMarkType('line')" data-mark="line"
title="Line chart">Line</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" :class="{ 'active': markType === 'area' }"
@click="setMarkType('area')" data-mark="area"
title="Area chart">Area</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 class="chart-builder-section">
<label class="chart-builder-label">Encodings</label>
<!-- X Axis -->
<div class="encoding-group">
<div class="encoding-row">
<label class="encoding-header">X Axis</label>
<select id="encoding-x-field" class="input" x-model="encodings.x.field"
@change="setEncodingField('x', $event.target.value)">
<option value="">None</option>
</select>
</div>
<div class="encoding-type">
<label class="encoding-type-label">Type</label>
<div class="type-toggle-group">
<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"
: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"
: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"
:class="{ 'active': encodings.x.type === 'temporal' }"
@click="setEncodingType('x', 'temporal')" data-encoding="x"
data-type="temporal" title="Temporal">T</button>
</div>
</div>
</div>
<!-- Y Axis -->
<div class="encoding-group">
<div class="encoding-row">
<label class="encoding-header">Y Axis</label>
<select id="encoding-y-field" class="input" x-model="encodings.y.field"
@change="setEncodingField('y', $event.target.value)">
<option value="">None</option>
</select>
</div>
<div class="encoding-type">
<label class="encoding-type-label">Type</label>
<div class="type-toggle-group">
<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"
: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"
: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"
:class="{ 'active': encodings.y.type === 'temporal' }"
@click="setEncodingType('y', 'temporal')" data-encoding="y"
data-type="temporal" title="Temporal">T</button>
</div>
</div>
</div>
<!-- Color -->
<div class="encoding-group">
<div class="encoding-row">
<label class="encoding-header">Color</label>
<select id="encoding-color-field" class="input" x-model="encodings.color.field"
@change="setEncodingField('color', $event.target.value)">
<option value="">None</option>
</select>
</div>
<div class="encoding-type">
<label class="encoding-type-label">Type</label>
<div class="type-toggle-group">
<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"
: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"
: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"
:class="{ 'active': encodings.color.type === 'temporal' }"
@click="setEncodingType('color', 'temporal')" data-encoding="color"
data-type="temporal" title="Temporal">T</button>
</div>
</div>
</div>
<!-- Size -->
<div class="encoding-group">
<div class="encoding-row">
<label class="encoding-header">Size</label>
<select id="encoding-size-field" class="input" x-model="encodings.size.field"
@change="setEncodingField('size', $event.target.value)">
<option value="">None</option>
</select>
</div>
<div class="encoding-type">
<label class="encoding-type-label">Type</label>
<div class="type-toggle-group">
<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"
: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"
: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"
: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 class="chart-builder-section">
<label class="chart-builder-label">Dimensions</label>
<div class="chart-dimensions-group">
<div class="dimension-input-group">
<label class="dimension-label">Width</label>
<input type="number" id="chart-width" class="input small" x-model.number="width"
@input="updatePreview()" placeholder="auto" min="1" />
</div>
<div class="dimension-input-group">
<label class="dimension-label">Height</label>
<input type="number" id="chart-height" class="input small"
x-model.number="height" @input="updatePreview()" placeholder="auto"
min="1" />
</div>
</div>
</div>
<div class="chart-builder-error" id="chart-builder-error"></div>
<div class="chart-builder-actions">
<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" @click="close()"
title="Cancel and close">Cancel</button>
</div>
</div>
<!-- Right Panel: Preview -->
<div class="chart-builder-preview">
<div class="chart-preview-header">Preview</div>
<div class="chart-preview-container" id="chart-builder-preview">
<div class="chart-preview-placeholder">
Configure chart to see preview
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Help Modal -->
<div id="help-modal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 700px; height: auto; max-height: 85vh;">
<div class="modal-header">
<span class="modal-title">Help</span>
<button class="btn btn-icon" id="help-modal-close" title="Close help (Escape)">×</button>
</div>
<div class="modal-body">
<div class="help-content">
<!-- About -->
<section class="help-section">
<h3 class="help-heading">About Astrolabe</h3>
<p class="help-text">
Astrolabe is a lightweight, browser-based snippet manager for Vega-Lite visualizations.
It's designed to help you quickly create, organize, and iterate on visualization specs
without
the overhead of a full development environment.
</p>
<p class="help-text">
Everything runs locally in your browser—no server, no signup, no data leaving your machine.
Your snippets and datasets are stored using browser storage, so they persist across
sessions.
As a Progressive Web App, Astrolabe works fully offline after your first visit and can be
installed
as a standalone application.
</p>
</section>
<!-- Key Features -->
<section class="help-section">
<h3 class="help-heading">Key Features</h3>
<ul class="help-list">
<li><strong>Three-panel workspace</strong> — Snippet library, Monaco code editor with
Vega-Lite schema validation, and live preview</li>
<li><strong>Draft/published workflow</strong> — Experiment safely without losing your
working version</li>
<li><strong>Dataset library</strong> — Store and reuse datasets across multiple
visualizations (supports JSON, CSV, TSV, TopoJSON)</li>
<li><strong>Offline-capable</strong> — Works without internet connection after first visit;
install as standalone app</li>
<li><strong>Import/export</strong> — Back up your work or move it between browsers</li>
<li><strong>Inline data extraction</strong> — Convert hardcoded data into reusable datasets
</li>
<li><strong>Search and sorting</strong> — Find snippets by name, comment, or spec content
</li>
</ul>
</section>
<!-- Quick Start -->
<section class="help-section">
<h3 class="help-heading">Getting Started</h3>
<div class="help-workflow">
<div class="help-step">
<strong>1. Create a snippet</strong>
<p>Click the "Create New Snippet" ghost card at the top of the snippet list. A sample
Vega-Lite spec loads automatically.</p>
</div>
<div class="help-step">
<strong>2. Edit in the Draft view</strong>
<p>Changes auto-save as you type. The preview updates automatically. Your published
version stays safe until you're ready.</p>
</div>
<div class="help-step">
<strong>3. Publish when ready</strong>
<p>Click "Publish" (or Cmd/Ctrl+S) to save your draft as the official version. Use
"Revert" if you want to discard changes.</p>
</div>
<div class="help-step">
<strong>4. Organize with datasets</strong>
<p>Open the Dataset Manager to create reusable datasets. Reference them in your specs
using <code>{"data": {"name": "dataset-name"}}</code>.</p>
</div>
</div>
</section>
<!-- Keyboard Shortcuts -->
<section class="help-section">
<h3 class="help-heading">Keyboard Shortcuts</h3>
<table class="help-shortcuts-table">
<tbody>
<tr>
<td class="shortcut-key">Cmd/Ctrl+Shift+N</td>
<td class="shortcut-desc">Create new snippet</td>
</tr>
<tr>
<td class="shortcut-key">Cmd/Ctrl+K</td>
<td class="shortcut-desc">Toggle dataset manager</td>
</tr>
<tr>
<td class="shortcut-key">Cmd/Ctrl+,</td>
<td class="shortcut-desc">Open settings</td>
</tr>
<tr>
<td class="shortcut-key">Cmd/Ctrl+S</td>
<td class="shortcut-desc">Publish draft (save)</td>
</tr>
<tr>
<td class="shortcut-key">Escape</td>
<td class="shortcut-desc">Close any open modal</td>
</tr>
</tbody>
</table>
</section>
<!-- Storage & Limits -->
<section class="help-section">
<h3 class="help-heading">Storage & Limits</h3>
<div class="help-warning">
<strong>⚠️ Important:</strong> All data is stored in your browser's local storage. If you
clear your browser cache or site data,
your snippets and datasets will be permanently deleted. Regular exports are recommended as
backups.
</div>
<ul class="help-list">
<li><strong>Snippets</strong> — Stored in localStorage with a 5 MB limit (shared across all
snippets). The storage monitor shows current usage.</li>
<li><strong>Datasets</strong> — Stored in IndexedDB with effectively unlimited space
(browser-dependent, typically 50 MB+).</li>
<li><strong>Backup</strong> — Use Import/Export to save your work as JSON files. Datasets
can be exported individually from the Dataset Manager.</li>
</ul>
</section>
<!-- Tips & Tricks -->
<section class="help-section">
<h3 class="help-heading">Tips & Tricks</h3>
<ul class="help-list">
<li><strong>Sort snippets</strong> — Use the sort buttons to organize by Modified, Created,
Name, or Size. Click a button twice to reverse the sort order (⬇ becomes ⬆).</li>
<li><strong>Extract inline data</strong> — When editing a spec with inline data, click
"Extract to Dataset" to create a reusable dataset automatically.</li>
<li><strong>Dataset references</strong> — Astrolabe resolves dataset references at render
time, so you can freely switch between inline and referenced data.</li>
<li><strong>Search across specs</strong> — The search box looks inside snippet names,
comments, and the spec content itself.</li>
<li><strong>Linked datasets</strong> — The metadata panel shows which datasets a snippet
uses, and the Dataset Manager shows which snippets reference each dataset.</li>
<li><strong>URL datasets</strong> — Reference remote data by URL. Astrolabe fetches and
caches it for preview, but the URL is what gets stored.</li>
</ul>
</section>
<!-- Privacy Policy -->
<section class="help-section">
<h3 class="help-heading">Privacy & Data</h3>
<p class="help-text">
<strong>Your data stays yours.</strong> Astrolabe is built with privacy as a core principle:
</p>
<ul class="help-list">
<li><strong>Local-first architecture</strong> — All snippets and datasets are stored in your
browser (localStorage and IndexedDB). Nothing is sent to any server.</li>
<li><strong>No accounts, no signup</strong> — There's no authentication system, no user
profiles, no cloud sync. Your work exists only on your machine.</li>
<li><strong>No cookies</strong> — Astrolabe doesn't use cookies or any persistent tracking
identifiers.</li>
<li><strong>Privacy-friendly analytics</strong> — We use GoatCounter (privacy-focused,
GDPR-compliant) to track basic usage patterns like "snippet created" or "dataset
exported." We collect <strong>zero personal information</strong>: no snippet names, no
dataset content, no IP addresses, no user identifiers. Just aggregate counts to
understand which features are used.</li>
<li><strong>Data portability</strong> — Export all your snippets and datasets anytime as
standard JSON/CSV/TSV files. No vendor lock-in.</li>
</ul>
<p class="help-text">
<strong>What analytics we collect:</strong> Action types (e.g., "snippet-create",
"dataset-export"), generic metadata (e.g., format types like JSON/CSV, counts like "5
snippets"). That's it.
</p>
<p class="help-text">
<strong>What we DON'T collect:</strong> Snippet names, dataset names, actual data content,
URLs, email addresses, or any personally identifiable information.
</p>
</section>
</div>
</div>
</div>
</div>
<!-- Donate Modal -->
<div id="donate-modal" class="modal" style="display: none;">
<div class="modal-content" style="height: auto;">
<div class="modal-header">
<span class="modal-title"></span>
<button class="btn btn-icon" id="donate-modal-close" title="Close (Escape)">×</button>
</div>
<div class="modal-body">
<div class="help-content">
<section class="help-section">
<h3 class="help-heading">Why Donate?</h3>
<p class="help-text">
If you are reading this, you probably found Astrolabe to be useful enough to support its
creators.
It is a free open-source project built in Kyiv, Ukraine.
<br>
<br>
This passion project of mine is possible because my relatives, friends, and colleagues took
arms and joined Armed Forces
to defend their country and loved ones against Russian invasion.
<br>
<br>
I feel deep gratitude to them, so I will humbly ask you to redirect your donations to the
foundations - you'll find the links below.
<br>
<br>
</section>
<div class="donate-two-column">
<section class="help-section">
<h3 class="help-heading">Where to Donate</h3>
<p class="help-text">
<ul class="help-list">
<li>
<strong><a
href="https://bank.gov.ua/en/news/all/natsionalniy-bank-vidkriv-spetsrahunok-dlya-zboru-koshtiv-na-potrebi-armiyi">
National Bank of Ukraine's special account for Ukraine's Armed Forces
</a> - Direct government channel</strong>
</li>
<li>
<strong><a href="https://savelife.in.ua/en/donate-en/#donate-army-card-once">
Come Back Alive Foundation</a></strong> - One of the biggest and most
trusted. They've been around since 2014 and share <a
href="https://savelife.in.ua/en/reporting-en/">detailed reports</a> of where
money goes.
</li>
<li>
<strong><a href="https://macpaw.foundation/">
MacPaw Foundation</a></strong> - Founded by MacPaw (where I work). Started
in 2016, shifted focus in 2022 to support the Defence Forces.
</li>
<li>
<strong><a href="https://foundation.kse.ua/en/humanitarian-projects/">
KSE Foundation</a></strong>. Kyiv School of Economics (where I teach).
Focuses on both education and humanitarian support for people and defenders
</li>
<li>
<strong><a href="https://standforukraine.com/">
Stand for Ukraine</a></strong> - Not a foundation, but an aggregator of
reliable organizations.
The list of fundraisers goes beyond military and covers recovery of veterans &
victims of war, shelter for the refugees and many more.
</li>
</ul>
</p>
</section>
<section class="help-section">
<h3 class="help-heading">Other Ways to Support</h3>
<p class="help-text">
Not able to donate? You can still help by:
</p>
<ul class="help-list">
<li>Sharing Astrolabe with colleagues and friends</li>
<li>Reporting bugs and suggesting improvements</li>
<li>Contributing code or documentation</li>
<li>Writing about your experience using Astrolabe</li>
</ul>
</section>
</div>
<section class="help-section">
<p class="help-text" style="text-align: center; font-style: italic;">
Thank you for considering this.
</p>
<p class="help-text" style="text-align: center; font-style: italic;">
<a href="https://olehomelchenko.com/en" target="_blank">Oleh Omelchenko</a>
</p>
</section>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal" style="display: none;" x-data="settingsPanel()">
<div class="modal-content" style="max-width: 600px; height: auto; max-height: 85vh;">
<div class="modal-header">
<span class="modal-title">Settings</span>
<button class="btn btn-icon" id="settings-modal-close" title="Close settings (Escape)">×</button>
</div>
<div class="modal-body">
<div class="settings-content">
<!-- Appearance Settings -->
<section class="settings-section">
<h3 class="settings-heading">Appearance</h3>
<div class="settings-item">
<label class="settings-label" for="setting-ui-theme">Theme</label>
<div class="settings-control">
<select id="setting-ui-theme" class="settings-select" x-model="uiTheme">
<option value="light">Light</option>
<option value="experimental">Dark (Experimental)</option>
</select>
</div>
</div>
</section>
<!-- Editor Settings -->
<section class="settings-section">
<h3 class="settings-heading">Editor</h3>
<div class="settings-item">
<label class="settings-label" for="setting-font-size">Font Size</label>
<div class="settings-control">
<input type="range" id="setting-font-size" min="10" max="18" step="1"
class="settings-slider" x-model.number="fontSize" />
<span class="settings-value" id="setting-font-size-value"
x-text="fontSize + 'px'"></span>
</div>
</div>
<div class="settings-item">
<label class="settings-label" for="setting-editor-theme">Editor Theme</label>
<div class="settings-control">
<select id="setting-editor-theme" class="settings-select" x-model="editorTheme">
<option value="vs-light">Light</option>
<option value="vs-dark">Dark</option>
<option value="hc-black">High Contrast</option>
</select>
</div>
</div>
<div class="settings-item">
<label class="settings-label" for="setting-tab-size">Tab Size</label>
<div class="settings-control">
<select id="setting-tab-size" class="settings-select" x-model.number="tabSize">
<option value="2">2 spaces</option>
<option value="4">4 spaces</option>
<option value="8">8 spaces</option>
</select>
</div>
</div>
<div class="settings-item">
<label class="settings-label">
<input type="checkbox" id="setting-minimap" class="settings-checkbox"
x-model="minimap" />
Show minimap
</label>
</div>
<div class="settings-item">
<label class="settings-label">
<input type="checkbox" id="setting-word-wrap" class="settings-checkbox"
x-model="wordWrap" />
Enable word wrap
</label>
</div>
<div class="settings-item">
<label class="settings-label">
<input type="checkbox" id="setting-line-numbers" class="settings-checkbox"
x-model="lineNumbers" />
Show line numbers
</label>
</div>
</section>
<!-- Performance Settings -->
<section class="settings-section">
<h3 class="settings-heading">Performance</h3>
<div class="settings-item">
<label class="settings-label" for="setting-render-debounce">Render Delay</label>
<div class="settings-control">
<input type="range" id="setting-render-debounce" min="300" max="3000" step="100"
class="settings-slider" x-model.number="renderDebounce" />
<span class="settings-value" id="setting-render-debounce-value"
x-text="renderDebounce + 'ms'"></span>
</div>
<div class="settings-hint">Delay before visualization updates while typing</div>
</div>
</section>
<!-- Formatting Settings -->
<section class="settings-section">
<h3 class="settings-heading">Formatting</h3>
<div class="settings-item">
<label class="settings-label" for="setting-date-format">Date Format</label>
<div class="settings-control">
<select id="setting-date-format" class="settings-select" x-model="dateFormat">
<option value="smart">Smart (relative times)</option>
<option value="locale">Locale (browser default)</option>
<option value="iso">ISO 8601</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
<div class="settings-item" id="custom-date-format-item" x-show="showCustomDateFormat">
<label class="settings-label" for="setting-custom-date-format">Custom Format</label>
<div class="settings-control">
<input type="text" id="setting-custom-date-format" class="settings-input"
placeholder="yyyy-MM-dd HH:mm" x-model="customDateFormat" />
</div>
<div class="settings-hint">
Tokens: yyyy (year), MM (month), dd (day), HH (24h), hh (12h), mm (min), ss (sec), a/A
(am/pm)
</div>
</div>
</section>
<!-- Actions -->
<div class="settings-actions">
<button class="btn btn-modal primary" id="settings-apply-btn" @click="apply()"
:disabled="!isDirty" title="Apply and save settings">Apply</button>
<button class="btn btn-modal" id="settings-reset-btn" @click="reset()"
title="Reset to default settings">Reset to Defaults</button>
<button class="btn btn-modal" id="settings-cancel-btn" @click="cancel()"
title="Cancel without saving">Cancel</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Notification Container -->
<div id="toast-container" x-data>
<template x-for="toast in $store.toasts.items" :key="toast.id">
<div :class="'toast toast-' + toast.type + (toast.visible ? ' toast-show' : '')">
<span class="toast-icon" x-text="$store.toasts.getIcon(toast.type)"></span>
<span class="toast-message" x-text="toast.message"></span>
<button class="toast-close" @click="$store.toasts.remove(toast.id)">×</button>
</div>
</template>
</div>
<script src="src/js/user-settings.js"></script>
<script src="src/js/generic-storage-ui.js"></script>
<script src="src/js/config.js"></script>
<script src="src/js/snippet-manager.js"></script>
<script src="src/js/dataset-manager.js"></script>
<script src="src/js/chart-builder.js"></script>
<script src="src/js/panel-manager.js"></script>
<script src="src/js/editor.js"></script>
<script src="src/js/app.js"></script>
<!-- GoatCounter Analytics -->
<script data-goatcounter="https://astrolabe.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
</body>
</html>