4 changed files with 794 additions and 0 deletions
-
1steps/src/theme.js
-
320zoomsketch-issue/123.html
-
314zoomsketch-issue/mermaid_live_editor.html
-
159zoomsketch-issue/visualizer-flowchart.html
@ -0,0 +1,320 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="utf-8" /> |
|||
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|||
<title>Visualizer — Flowchart</title> |
|||
|
|||
<!-- Tailwind CDN (ok for local/doc use) --> |
|||
<script src="https://cdn.tailwindcss.com"></script> |
|||
|
|||
<!-- Google font --> |
|||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet"> |
|||
|
|||
<!-- html2canvas for quick export --> |
|||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js" integrity="sha512-BNa5xQe3q3G1tQ7g7ARb8+1JYkK1lqVq3V4b5wE3xD1c7u6hQ1mJx0nQjS6sC6sKz8X0G5yjvVb5G5n7bQq2w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> |
|||
|
|||
<style> |
|||
:root{ |
|||
--bg: #f8fafc; |
|||
--panel: #ffffff; |
|||
--muted: #6b7280; |
|||
--accent: #0ea5a4; |
|||
--accent-2: #2563eb; |
|||
--danger: #ef4444; |
|||
--card-radius: 12px; |
|||
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; |
|||
} |
|||
body { background: var(--bg); color: #0f172a; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; } |
|||
.page { max-width: 1100px; margin: 36px auto; padding: 24px; } |
|||
.card { background: var(--panel); border-radius: var(--card-radius); box-shadow: 0 6px 18px rgba(2,6,23,0.06); padding: 18px; } |
|||
.stage-title { font-weight:700; color: #0f172a; } |
|||
.node { border-radius:10px; padding:10px 14px; border:1px solid rgba(2,6,23,0.06); box-shadow: 0 2px 6px rgba(2,6,23,0.04); } |
|||
.node.small { padding:8px 10px; font-size:0.92rem; } |
|||
.node.step { background: #eef2ff; border-left: 4px solid #6366f1; } |
|||
.node.process { background: #ecfdf5; border-left: 4px solid #10b981; } |
|||
.node.io { background: #fff7ed; border-left: 4px solid #f59e0b; } |
|||
.node.terminator { background:#f1f5f9; border-left:4px solid #94a3b8; font-weight:600; } |
|||
.node.decision { background:#fff7f7; border-left:4px solid var(--danger); transform: skewX(-10deg); } |
|||
.decision .label { display:inline-block; transform: skewX(10deg); } |
|||
|
|||
/* grid layout for stages */ |
|||
.stages { display:grid; grid-template-columns: 1fr; gap:18px; margin-top:18px; } |
|||
@media(min-width:980px){ .stages { grid-template-columns: 1fr 1fr; } } |
|||
|
|||
/* connectors (svg container) */ |
|||
.connector { display:flex; align-items:center; justify-content:center; margin:12px 0; } |
|||
|
|||
/* legend */ |
|||
.legend-item { display:flex; gap:8px; align-items:center; } |
|||
.legend-swatch { width:14px; height:14px; border-radius:4px; } |
|||
|
|||
/* tooltip */ |
|||
.tooltip { position:relative; } |
|||
.tooltip [data-tip] { position:relative; cursor:help; } |
|||
.tooltip [data-tip]:focus { outline: 2px dashed #c7d2fe; outline-offset:4px; } |
|||
.tooltip [data-tip]::after { |
|||
content: attr(data-tip); |
|||
position:absolute; |
|||
left:50%; |
|||
transform: translateX(-50%); |
|||
bottom: calc(100% + 10px); |
|||
background:#0b1220; |
|||
color:#fff; font-size:13px; padding:8px 10px; |
|||
border-radius:8px; white-space:nowrap; display:none; z-index:40; |
|||
} |
|||
.tooltip [data-tip]:hover::after, .tooltip [data-tip]:focus::after { display:block; } |
|||
|
|||
/* subtle nodes grid inside stage */ |
|||
.stage-grid { display:flex; flex-direction:column; gap:12px; } |
|||
.stage-row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; } |
|||
|
|||
/* export button */ |
|||
.export-btn { background:linear-gradient(90deg,var(--accent-2),var(--accent)); color:#fff; padding:8px 12px; border-radius:8px; font-weight:600; cursor:pointer; } |
|||
|
|||
/* print-friendly adjustments */ |
|||
@media print { |
|||
.export-btn, .controls { display:none !important; } |
|||
body { background:white; } |
|||
.page { margin: 0; box-shadow:none; } |
|||
} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<main class="page"> |
|||
<header class="flex items-start justify-between gap-4 mb-6"> |
|||
<div> |
|||
<h1 class="text-2xl stage-title">Visualizer — Application Flowchart</h1> |
|||
<p class="text-sm text-gray-600 mt-1">Clear, staged view of initialization, data loading, and playback sync. Shows the recommended two-stage video loading fix (metadata vs buffering).</p> |
|||
</div> |
|||
<div class="controls flex gap-3 items-center"> |
|||
<button id="exportBtn" class="export-btn" title="Export flowchart as PNG">Export PNG</button> |
|||
<a href="#" id="printBtn" class="text-sm text-gray-600 hover:underline">Print</a> |
|||
</div> |
|||
</header> |
|||
|
|||
<!-- Top summary card --> |
|||
<section class="card mb-5"> |
|||
<div class="flex items-start justify-between gap-6"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold">Overview</h2> |
|||
<p class="text-sm text-gray-600 mt-1">The visualizer loads JSON radar data and a video file, synchronizes timestamps, creates p5 sketches, then runs a high-precision animation loop to keep video and radar frames in sync.</p> |
|||
</div> |
|||
<div class="text-sm"> |
|||
<div class="mb-2"><strong>Key events</strong></div> |
|||
<div class="flex flex-col gap-1 text-gray-700"> |
|||
<div><span class="font-medium">loadedmetadata</span> → duration available (Stage A)</div> |
|||
<div><span class="font-medium">canplaythrough</span> → buffering sufficient (Stage B)</div> |
|||
<div><span class="font-medium">requestAnimationFrame</span> / <span class="font-medium">requestVideoFrameCallback</span> → high precision playback</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- Main flowchart area --> |
|||
<div id="chartRoot" class="card mt-2 p-6"> |
|||
<div class="stages"> |
|||
|
|||
<!-- Stage: Initialization --> |
|||
<section aria-labelledby="s1" class="p-4"> |
|||
<div class="flex items-center justify-between mb-3"> |
|||
<h3 id="s1" class="text-lg font-semibold">Stage 1 — Initialization</h3> |
|||
<span class="text-sm text-gray-500">App boot & cache check</span> |
|||
</div> |
|||
|
|||
<div class="stage-grid"> |
|||
<div class="stage-row"> |
|||
<div class="node terminator">Start application</div> |
|||
<div class="connector" aria-hidden><svg width="48" height="28" viewBox="0 0 48 28"><path d="M2 14 L46 14" stroke="#cbd5e1" stroke-width="2" fill="none"/><polygon points="40,10 46,14 40,18" fill="#cbd5e1" /></svg></div> |
|||
<div class="node process">DOMContentLoaded → initTheme() & initDB()</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node decision tooltip small" tabindex="0" data-tip="Check localStorage for saved filenames & load from IndexedDB if present.">Check localStorage for session?</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node io">If yes: load json & video blobs from IndexedDB</div> |
|||
<div class="connector" aria-hidden><svg width="58" height="28" viewBox="0 0 58 28"><path d="M2 14 L56 14" stroke="#cbd5e1" stroke-width="2" fill="none"/><polygon points="50,10 56,14 50,18" fill="#cbd5e1" /></svg></div> |
|||
<div class="node step">Call handleFiles([...blobs]) → processFilePipeline()</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- Stage: Data load --> |
|||
<section aria-labelledby="s2" class="p-4"> |
|||
<div class="flex items-center justify-between mb-3"> |
|||
<h3 id="s2" class="text-lg font-semibold">Stage 2 — Data Loading & Finalization</h3> |
|||
<span class="text-sm text-gray-500">Two-stage video loading (robust)</span> |
|||
</div> |
|||
|
|||
<div class="stage-grid"> |
|||
<div class="stage-row"> |
|||
<div class="node step">processFilePipeline() starts</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node io tooltip" tabindex="0" data-tip="Show a modal with spinner and progress text. Keep it open until Stage B resolves.">Show loading modal + spinner</div> |
|||
|
|||
<div class="connector" aria-hidden style="margin-left:8px;"> |
|||
<svg width="56" height="28" viewBox="0 0 56 28"><path d="M2 14 L54 14" stroke="#cbd5e1" stroke-width="2" fill="none"/><polygon points="48,10 54,14 48,18" fill="#cbd5e1" /></svg> |
|||
</div> |
|||
|
|||
<div class="node decision tooltip small" tabindex="0" data-tip="If JSON present → parse via web worker; update progress; set appState.vizData.">JSON file present?</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process">Parse JSON in worker → parseVisualizationJson()</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node decision tooltip small" tabindex="0" data-tip="If you have a video file, start video loading.">Video file present?</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process tooltip" tabindex="0" data-tip="Stage A: attach loadedmetadata listener. Stage B: attach canplaythrough & progress listeners + fallback timer.">Setup video player & attach listeners</div> |
|||
</div> |
|||
|
|||
<!-- Emphasize the two critical sub-stages --> |
|||
<div class="stage-row"> |
|||
<div class="node process" style="flex:1"> |
|||
<strong>Stage A — Metadata (DATA init)</strong> |
|||
<div class="text-sm text-gray-600 mt-1">Wait for <code>loadedmetadata</code> → video.duration available → call <code>finalizeSetup()</code> (create p5 sketches, draw static graphs).</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process" style="flex:1"> |
|||
<strong>Stage B — Buffering (UI finalization)</strong> |
|||
<div class="text-sm text-gray-600 mt-1">Wait for <code>canplaythrough</code> (or fallback timeout/loadeddata) → stop spinner and <strong>hide modal</strong>.</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process text-red-700" style="border-left-color: #ef4444;"> |
|||
Re-sync radar timestamps (CRITICAL) — update each frame.timestampMs relative to videoStartDate & offset |
|||
<div class="text-xs text-gray-600 mt-1">function: <code>calculateAndSetOffset()</code> + timestamp normalization before finalize</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process">Create p5 instances: radarSketch, speedGraphSketch, zoomSketch</div> |
|||
<div class="connector" aria-hidden> |
|||
<svg width="64" height="28" viewBox="0 0 64 28"><path d="M2 14 L62 14" stroke="#cbd5e1" stroke-width="2" fill="none"/><polygon points="56,10 62,14 56,18" fill="#cbd5e1" /></svg> |
|||
</div> |
|||
<div class="node terminator">Set initial frame → updateFrame(0)</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node io tooltip" tabindex="0" data-tip="Hiding modal indicates UI is ready — play is safe.">Hide loading modal</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- Stage: Playback --> |
|||
<section aria-labelledby="s3" class="p-4"> |
|||
<div class="flex items-center justify-between mb-3"> |
|||
<h3 id="s3" class="text-lg font-semibold">Stage 3 — Playback & Synchronization</h3> |
|||
<span class="text-sm text-gray-500">High-precision sync loop</span> |
|||
</div> |
|||
|
|||
<div class="stage-grid"> |
|||
<div class="stage-row"> |
|||
<div class="node step">User clicks Play</div> |
|||
<div class="connector" aria-hidden> |
|||
<svg width="50" height="28" viewBox="0 0 50 28"><path d="M2 14 L48 14" stroke="#cbd5e1" stroke-width="2" fill="none"/><polygon points="42,10 48,14 42,18" fill="#cbd5e1" /></svg> |
|||
</div> |
|||
<div class="node process">Start animationLoop() (requestAnimationFrame) & video.play()</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process">Compute high-precision time & map to radar timestamp</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process">Find matching radar frame index (binary search / lookup)</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node io">Update UI overlays, redraw p5 sketches, update timeline</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node decision small">Is drift > threshold (e.g. 150 ms)?</div> |
|||
</div> |
|||
|
|||
<div class="stage-row"> |
|||
<div class="node process">If yes → resync video player currentTime and reset master timers</div> |
|||
<div class="node step">Continue loop</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
|
|||
<!-- Legend & Troubleshooting --> |
|||
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4"> |
|||
<div class="card p-4"> |
|||
<h4 class="font-semibold mb-2">Legend</h4> |
|||
<div class="flex flex-col gap-2 text-sm text-gray-700"> |
|||
<div class="legend-item"><span class="legend-swatch" style="background:#eef2ff"></span> <span>Step — UI / user action</span></div> |
|||
<div class="legend-item"><span class="legend-swatch" style="background:#ecfdf5"></span> <span>Process — internal data processing</span></div> |
|||
<div class="legend-item"><span class="legend-swatch" style="background:#fff7ed"></span> <span>I/O — modal / loading / network</span></div> |
|||
<div class="legend-item"><span class="legend-swatch" style="background:#fff7f7"></span> <span>Decision — branching</span></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="card p-4"> |
|||
<h4 class="font-semibold mb-2">Troubleshooting checklist</h4> |
|||
<ol class="text-sm text-gray-700 ml-4 list-decimal space-y-2"> |
|||
<li><strong>loadedmetadata</strong> must fire before calling <code>setData(..., videoPlayer.duration)</code>. If graphs are blank, confirm timeline shows duration > 0.</li> |
|||
<li>If modal never hides, verify <code>canplaythrough</code> or fallback timer resolves. Some codecs/browsers don't reliably emit canplaythrough — use a timeout fallback.</li> |
|||
<li>On cached reload (IndexedDB) the ordering may differ — ensure both paths run the same Stage A → Stage B sequence.</li> |
|||
<li>Guard <code>finalizeSetup()</code> with a flag so it only runs once (prevents double initialization).</li> |
|||
</ol> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- small notes --> |
|||
<div class="mt-6 text-xs text-gray-500"> |
|||
<strong>Notes:</strong> This diagram reflects the robust two-stage loading approach: Stage A for data initialization (metadata) and Stage B for UI/buffering. Use the export button to paste into reports or attach to bug tickets. |
|||
</div> |
|||
</div> |
|||
</main> |
|||
|
|||
<script> |
|||
// Export functionality: capture the chartRoot area and download PNG |
|||
document.getElementById('exportBtn').addEventListener('click', async () => { |
|||
const root = document.getElementById('chartRoot'); |
|||
try { |
|||
const canvas = await html2canvas(root, { scale: 2, useCORS: true, backgroundColor: null }); |
|||
const url = canvas.toDataURL('image/png'); |
|||
const a = document.createElement('a'); |
|||
a.href = url; |
|||
a.download = 'visualizer-flowchart.png'; |
|||
document.body.appendChild(a); |
|||
a.click(); |
|||
a.remove(); |
|||
} catch (err) { |
|||
alert('Export failed: ' + err.message); |
|||
} |
|||
}); |
|||
|
|||
// Print button |
|||
document.getElementById('printBtn').addEventListener('click', (e) => { |
|||
e.preventDefault(); |
|||
window.print(); |
|||
}); |
|||
|
|||
// Add simple keyboard focus support for tooltips (accessibility) |
|||
document.querySelectorAll('.tooltip [data-tip]').forEach(el => { |
|||
el.addEventListener('keydown', (ev) => { |
|||
if (ev.key === 'Enter' || ev.key === ' ') { |
|||
ev.preventDefault(); |
|||
// toggle a hover-like state by focusing then blurring quickly to show ::after |
|||
el.focus(); |
|||
} |
|||
}); |
|||
}); |
|||
</script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,314 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="utf-8" /> |
|||
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|||
<title>Mermaid Live Editor</title> |
|||
|
|||
<!-- Tailwind CSS CDN (ok for local use / demo) --> |
|||
<script src="https://cdn.tailwindcss.com"></script> |
|||
|
|||
<style> |
|||
/* Minimal extra styling */ |
|||
html,body { height:100%; } |
|||
body { background: #f8fafc; } |
|||
/* ensure the preview container expands and scrolls for large diagrams */ |
|||
#preview { |
|||
width: 100%; |
|||
height: 100%; |
|||
min-height: 400px; |
|||
overflow: auto; |
|||
display: flex; |
|||
align-items: flex-start; |
|||
justify-content: center; |
|||
padding: 1rem; |
|||
background: white; |
|||
border-radius: 0.5rem; |
|||
border: 1px solid rgba(0,0,0,0.06); |
|||
} |
|||
/* make the rendered svg responsive */ |
|||
#preview svg { |
|||
max-width: 100%; |
|||
height: auto; |
|||
} |
|||
/* small monospace editor */ |
|||
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace; } |
|||
.error { color: #b91c1c; } |
|||
</style> |
|||
</head> |
|||
<body class="p-6"> |
|||
|
|||
<div class="max-w-7xl mx-auto"> |
|||
<header class="mb-4"> |
|||
<h1 class="text-2xl font-semibold">Mermaid Live Editor</h1> |
|||
<p class="text-sm text-gray-600">Paste Mermaid code on the left, render on the right. Uses Mermaid v11+ (ESM) and html2canvas for PNG export.</p> |
|||
</header> |
|||
|
|||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> |
|||
<!-- Editor Column --> |
|||
<section class="space-y-3"> |
|||
<div class="flex items-center gap-2"> |
|||
<label class="text-sm font-medium">Theme</label> |
|||
<select id="themeSelect" class="px-2 py-1 border rounded"> |
|||
<option value="neutral">neutral (default)</option> |
|||
<option value="default">default</option> |
|||
<option value="dark">dark</option> |
|||
<option value="forest">forest</option> |
|||
</select> |
|||
|
|||
<label class="flex items-center gap-1 ml-4 text-sm"> |
|||
<input id="autoRender" type="checkbox" class="border" /> |
|||
<span>Auto-render</span> |
|||
</label> |
|||
|
|||
<button id="renderBtn" class="ml-auto px-3 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700">Render</button> |
|||
<button id="exportBtn" class="px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700">Export PNG</button> |
|||
</div> |
|||
|
|||
<div class="flex gap-2"> |
|||
<button id="sampleBtn" class="px-2 py-1 bg-gray-200 rounded">Load sample</button> |
|||
<button id="clearBtn" class="px-2 py-1 bg-gray-200 rounded">Clear</button> |
|||
<button id="zoomIn" class="px-2 py-1 bg-gray-200 rounded">Zoom +</button> |
|||
<button id="zoomOut" class="px-2 py-1 bg-gray-200 rounded">Zoom −</button> |
|||
<span id="zoomPct" class="text-sm text-gray-600 ml-2">100%</span> |
|||
</div> |
|||
|
|||
<label class="text-xs text-gray-600">Mermaid source</label> |
|||
<textarea id="editor" rows="18" class="w-full border rounded p-3 text-sm" spellcheck="false"></textarea> |
|||
|
|||
<div id="errorBox" class="p-2 text-sm hidden rounded border border-red-200 bg-red-50 error"></div> |
|||
</section> |
|||
|
|||
<!-- Preview Column --> |
|||
<section> |
|||
<label class="text-xs text-gray-600">Preview</label> |
|||
<div id="preview" class="mt-2"> |
|||
<div id="renderContainer" style="transform-origin: top left;"> |
|||
<!-- rendered SVG will be injected here --> |
|||
<div id="placeholder" class="text-gray-400 p-6">Rendered diagram will appear here.</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
|
|||
<footer class="mt-4 text-xs text-gray-500"> |
|||
<div>Mermaid initialized with <code>startOnLoad: false</code> and <code>theme: "neutral"</code>.</div> |
|||
<div class="mt-1">If you see an error, the editor will show a clear message. Large diagrams are allowed — use zoom and scroll.</div> |
|||
</footer> |
|||
</div> |
|||
|
|||
<!-- html2canvas for PNG export --> |
|||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script> |
|||
|
|||
<!-- Mermaid ESM import and main logic --> |
|||
<script type="module"> |
|||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"; |
|||
|
|||
// Initialize as requested |
|||
mermaid.initialize({ startOnLoad: false, theme: "neutral" }); |
|||
|
|||
const editor = document.getElementById('editor'); |
|||
const renderBtn = document.getElementById('renderBtn'); |
|||
const preview = document.getElementById('preview'); |
|||
const renderContainer = document.getElementById('renderContainer'); |
|||
const errorBox = document.getElementById('errorBox'); |
|||
const themeSelect = document.getElementById('themeSelect'); |
|||
const autoRender = document.getElementById('autoRender'); |
|||
const exportBtn = document.getElementById('exportBtn'); |
|||
const sampleBtn = document.getElementById('sampleBtn'); |
|||
const clearBtn = document.getElementById('clearBtn'); |
|||
const zoomIn = document.getElementById('zoomIn'); |
|||
const zoomOut = document.getElementById('zoomOut'); |
|||
const zoomPct = document.getElementById('zoomPct'); |
|||
let zoom = 1; |
|||
|
|||
// sample mermaid content |
|||
const sample = `flowchart TD |
|||
A[Start] --> B{Is it working?} |
|||
B -- Yes --> C[Celebrate] |
|||
B -- No --> D[Debug] |
|||
D --> B |
|||
C --> E[Deploy] |
|||
`; |
|||
|
|||
// Helper: set error message or hide |
|||
function showError(msg) { |
|||
if (!msg) { |
|||
errorBox.classList.add('hidden'); |
|||
errorBox.textContent = ''; |
|||
} else { |
|||
errorBox.classList.remove('hidden'); |
|||
errorBox.textContent = msg; |
|||
} |
|||
} |
|||
|
|||
// Render function (robust to different mermaid.render return shapes) |
|||
async function renderMermaid() { |
|||
const code = editor.value.trim(); |
|||
if (!code) { |
|||
showError("Editor is empty — paste some Mermaid code and click Render."); |
|||
clearPreview(); |
|||
return; |
|||
} |
|||
showError(null); |
|||
|
|||
// Create unique id for this render |
|||
const id = "mermaid-" + Math.random().toString(36).slice(2, 9); |
|||
|
|||
try { |
|||
// Try to parse first (mermaid.parse may throw for invalid code) |
|||
// Not all mermaid builds expose parse; guard it. |
|||
if (typeof mermaid.parse === 'function') { |
|||
try { |
|||
mermaid.parse(code); |
|||
} catch (parseErr) { |
|||
// parse() may throw an error object with message |
|||
throw new Error(parseErr?.message || String(parseErr)); |
|||
} |
|||
} |
|||
|
|||
// mermaid.render may return either a string or an object/promise depending on build. |
|||
const result = await mermaid.render(id, code); |
|||
|
|||
// result could be a string (svg) or an object like { svg: '...', bindFunctions: ... } |
|||
let svg; |
|||
if (!result) { |
|||
throw new Error("mermaid.render returned empty result."); |
|||
} else if (typeof result === 'string') { |
|||
svg = result; |
|||
} else if (typeof result === 'object' && result.svg) { |
|||
svg = result.svg; |
|||
} else { |
|||
// fallback: try to stringify |
|||
svg = String(result); |
|||
} |
|||
|
|||
// Inject svg into container |
|||
renderContainer.innerHTML = svg; |
|||
|
|||
// Remove placeholder if present |
|||
const placeholder = document.getElementById('placeholder'); |
|||
if (placeholder) placeholder.remove(); |
|||
|
|||
// Reset zoom transform origin and scale |
|||
renderContainer.style.transform = `scale(${zoom})`; |
|||
|
|||
showError(null); |
|||
} catch (err) { |
|||
// Provide readable error messages |
|||
const message = (err && err.message) ? err.message : String(err); |
|||
showError("Mermaid parse/render error: " + message); |
|||
// Attempt to clear/keep previous preview but indicate error |
|||
// Do not wipe the container so user can inspect last diagram if any |
|||
} |
|||
} |
|||
|
|||
function clearPreview() { |
|||
renderContainer.innerHTML = '<div id="placeholder" class="text-gray-400 p-6">Rendered diagram will appear here.</div>'; |
|||
renderContainer.style.transform = `scale(${zoom})`; |
|||
} |
|||
|
|||
// Expose UI interactions |
|||
renderBtn.addEventListener('click', () => { |
|||
renderMermaid(); |
|||
}); |
|||
|
|||
// Auto-render (debounced) |
|||
let autosaveTimer = null; |
|||
editor.addEventListener('input', () => { |
|||
if (autoRender.checked) { |
|||
if (autosaveTimer) clearTimeout(autosaveTimer); |
|||
autosaveTimer = setTimeout(() => { |
|||
renderMermaid(); |
|||
}, 450); // gentle debounce |
|||
} |
|||
}); |
|||
|
|||
// Theme switch |
|||
themeSelect.addEventListener('change', () => { |
|||
const theme = themeSelect.value || 'neutral'; |
|||
mermaid.initialize({ startOnLoad: false, theme: theme }); |
|||
// re-render (if code exists) |
|||
renderMermaid(); |
|||
}); |
|||
|
|||
// Export PNG using html2canvas |
|||
exportBtn.addEventListener('click', async () => { |
|||
showError(null); |
|||
// render first to ensure latest code is shown |
|||
await renderMermaid(); |
|||
|
|||
// Wait a tick for DOM to update |
|||
await new Promise(r => setTimeout(r, 50)); |
|||
|
|||
// html2canvas on the renderContainer (which contains the svg) |
|||
try { |
|||
// Temporarily set background white for export if theme isn't dark |
|||
const originalBg = renderContainer.style.backgroundColor; |
|||
renderContainer.style.backgroundColor = getComputedStyle(preview).backgroundColor || '#ffffff'; |
|||
|
|||
const canvas = await html2canvas(renderContainer, { |
|||
backgroundColor: null, |
|||
scale: Math.min(2, window.devicePixelRatio || 1), // better resolution but bounded |
|||
useCORS: true, |
|||
logging: false |
|||
}); |
|||
|
|||
// restore |
|||
renderContainer.style.backgroundColor = originalBg || ''; |
|||
|
|||
const a = document.createElement('a'); |
|||
a.download = 'mermaid-diagram.png'; |
|||
a.href = canvas.toDataURL('image/png'); |
|||
a.click(); |
|||
} catch (err) { |
|||
showError('Export failed: ' + (err?.message || String(err))); |
|||
} |
|||
}); |
|||
|
|||
// sample / clear |
|||
sampleBtn.addEventListener('click', () => { |
|||
editor.value = sample; |
|||
if (autoRender.checked) renderMermaid(); |
|||
}); |
|||
clearBtn.addEventListener('click', () => { |
|||
editor.value = ''; |
|||
clearPreview(); |
|||
}); |
|||
|
|||
// zoom controls |
|||
function setZoom(newZoom) { |
|||
zoom = Math.max(0.2, Math.min(3, newZoom)); |
|||
renderContainer.style.transform = `scale(${zoom})`; |
|||
zoomPct.textContent = Math.round(zoom * 100) + '%'; |
|||
} |
|||
zoomIn.addEventListener('click', () => setZoom(zoom + 0.2)); |
|||
zoomOut.addEventListener('click', () => setZoom(zoom - 0.2)); |
|||
|
|||
// initialize editor with sample |
|||
editor.value = sample; |
|||
// initial mermaid theme is neutral as requested |
|||
themeSelect.value = 'neutral'; |
|||
setZoom(1); |
|||
|
|||
// optionally auto-render at startup |
|||
// (we don't start rendering regardless so developer can control) |
|||
// If you want initial render uncomment next line: |
|||
// renderMermaid(); |
|||
|
|||
// Accessibility: keyboard shortcut Ctrl/Cmd+Enter to render |
|||
window.addEventListener('keydown', (e) => { |
|||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { |
|||
e.preventDefault(); |
|||
renderMermaid(); |
|||
} |
|||
}); |
|||
|
|||
// Resize handling for very large diagrams — keep transform origin consistent |
|||
new ResizeObserver(() => { |
|||
renderContainer.style.transformOrigin = 'top left'; |
|||
}).observe(preview); |
|||
|
|||
</script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,159 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="utf-8" /> |
|||
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|||
<title>Visualizer — Interactive Mermaid Flowchart</title> |
|||
|
|||
<!-- Tailwind for quick layout (fine for dev) --> |
|||
<script src="https://cdn.tailwindcss.com"></script> |
|||
|
|||
<!-- html2canvas for export (no integrity attr) --> |
|||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js" crossorigin="anonymous"></script> |
|||
|
|||
<style> |
|||
:root { |
|||
--bg: #f8fafb; |
|||
--card: #ffffff; |
|||
--muted: #64748b; |
|||
--accent: #2563eb; |
|||
--accent2: #0ea5a4; |
|||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; |
|||
} |
|||
body { background: var(--bg); color: #0f172a; -webkit-font-smoothing: antialiased; } |
|||
.page { max-width: 1200px; margin: 36px auto; padding: 20px; } |
|||
.card { background: var(--card); border-radius: 12px; box-shadow: 0 6px 18px rgba(2,6,23,0.06); padding: 18px; } |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<main class="page"> |
|||
<header class="flex items-start justify-between gap-4 mb-6"> |
|||
<div> |
|||
<h1 class="text-2xl font-semibold">Visualizer — Interactive Flowchart (Mermaid)</h1> |
|||
<p class="text-sm text-gray-600 mt-1">Paste Mermaid source → instantly render a flowchart beside it.</p> |
|||
</div> |
|||
|
|||
<div class="controls flex items-center gap-3 export-area"> |
|||
<button id="exportPngBtn">Export PNG</button> |
|||
<button id="copyBtn" class="small-btn">Copy Source</button> |
|||
<button id="rerenderBtn" class="small-btn">Re-render</button> |
|||
<select id="themeSelect" class="small-btn"> |
|||
<option value="neutral" selected>Neutral</option> |
|||
<option value="default">Default</option> |
|||
<option value="forest">Forest</option> |
|||
<option value="dark">Dark</option> |
|||
<option value="solarized">Solarized</option> |
|||
</select> |
|||
</div> |
|||
</header> |
|||
|
|||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> |
|||
<!-- Diagram --> |
|||
<section class="card"> |
|||
<div class="flex items-center justify-between mb-3"> |
|||
<h2 class="text-lg font-medium">Flowchart</h2> |
|||
<div class="legend"> |
|||
<span class="font-semibold">Legend:</span> Stage A = metadata • Stage B = buffering |
|||
</div> |
|||
</div> |
|||
<div id="mermaidContainer" class="bg-white rounded-lg p-4 min-h-[200px]"> |
|||
<div id="mermaidRender" class="mermaid"></div> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- Source --> |
|||
<aside class="card"> |
|||
<h3 class="font-semibold mb-2">Mermaid Source (editable)</h3> |
|||
<pre id="mermaidSource" class="mermaid-source" contenteditable="true" spellcheck="false"></pre> |
|||
<p class="note mt-2">Press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> or click “Re-render”.</p> |
|||
</aside> |
|||
</div> |
|||
</main> |
|||
|
|||
<!-- Mermaid v11 as ES module --> |
|||
<script type="module"> |
|||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"; |
|||
|
|||
// ✅ initial diagram (starts with 'flowchart TB') |
|||
const initialText = `flowchart TB |
|||
A[Start] --> B[Load resources] |
|||
B --> C{Ready?} |
|||
C -->|Yes| D[Initialize app] |
|||
C -->|No| E([Wait]) |
|||
D --> F([Done])`; |
|||
|
|||
const src = document.getElementById("mermaidSource"); |
|||
const renderArea = document.getElementById("mermaidRender"); |
|||
|
|||
// set source text content |
|||
src.textContent = initialText; |
|||
|
|||
async function initMermaid(theme = "neutral") { |
|||
mermaid.initialize({ |
|||
startOnLoad: false, |
|||
theme, |
|||
securityLevel: "loose", |
|||
flowchart: { curve: "linear" } |
|||
}); |
|||
await mermaid.run(); // ensures theme & config loaded |
|||
} |
|||
|
|||
async function renderMermaid(text) { |
|||
const code = (text || "").trim(); |
|||
if (!code.startsWith("flowchart") && !code.startsWith("graph")) { |
|||
renderArea.innerHTML = `<pre style="color:#ef4444;">❌ No valid diagram type detected. Make sure your text begins with 'flowchart TB' or 'graph LR'.</pre>`; |
|||
return; |
|||
} |
|||
try { |
|||
const { svg } = await mermaid.render("mmdDiagram", code); |
|||
renderArea.innerHTML = svg; |
|||
} catch (err) { |
|||
renderArea.innerHTML = `<pre style="color:#ef4444;">Error: ${err.message}</pre>`; |
|||
console.error(err); |
|||
} |
|||
} |
|||
|
|||
// ✅ initialize Mermaid first |
|||
await initMermaid(); |
|||
// ✅ render initial diagram |
|||
await renderMermaid(initialText); |
|||
|
|||
// Re-render button |
|||
document.getElementById("rerenderBtn").addEventListener("click", () => { |
|||
const text = src.innerText.trim(); |
|||
renderMermaid(text); |
|||
}); |
|||
|
|||
// Theme selector |
|||
document.getElementById("themeSelect").addEventListener("change", async e => { |
|||
await initMermaid(e.target.value); |
|||
await renderMermaid(src.innerText.trim()); |
|||
}); |
|||
|
|||
// Copy source |
|||
document.getElementById("copyBtn").addEventListener("click", async () => { |
|||
await navigator.clipboard.writeText(src.innerText.trim()); |
|||
alert("Copied Mermaid source!"); |
|||
}); |
|||
|
|||
// Export PNG |
|||
document.getElementById("exportPngBtn").addEventListener("click", async () => { |
|||
const node = document.getElementById("mermaidContainer"); |
|||
const canvas = await html2canvas(node, { scale: 2, backgroundColor: null }); |
|||
const a = document.createElement("a"); |
|||
a.href = canvas.toDataURL("image/png"); |
|||
a.download = "flowchart.png"; |
|||
a.click(); |
|||
}); |
|||
|
|||
// Ctrl+Enter shortcut |
|||
document.addEventListener("keydown", e => { |
|||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { |
|||
e.preventDefault(); |
|||
renderMermaid(src.innerText.trim()); |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
</body> |
|||
</html> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue