769 lines
23 KiB
HTML
769 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CI/CD Pipeline — Reference Template</title>
|
|
<!--
|
|
Reference template for the ck:preview skill: Mermaid diagrams.
|
|
Teal/cyan palette — distinctly different from terracotta (architecture)
|
|
and rose (data-table) templates so agents absorb variety.
|
|
Key patterns demonstrated:
|
|
- Teal/cyan palette (NOT indigo/violet)
|
|
- Dot-grid background atmosphere (different from radial gradient)
|
|
- Large heading (38px) for typographic contrast
|
|
- ESM import of Mermaid + @mermaid-js/layout-elk
|
|
- Mermaid theme: 'base' + full themeVariables, fontSize: 16px
|
|
- CSS overrides: .nodeLabel 16px, .edgeLabel 13px
|
|
- look: 'classic' for clean lines
|
|
- layout: 'elk' for better node positioning
|
|
- Zoom controls with scroll-to-zoom and drag-to-pan
|
|
- Both light and dark themes via prefers-color-scheme
|
|
- Staggered fade-in, reduced motion respect
|
|
-->
|
|
<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=Bricolage+Grotesque:wght@400;500;600;700&family=Fragment+Mono:wght@400&display=swap" rel="stylesheet">
|
|
<style>
|
|
/* ============ THEME ============ */
|
|
:root {
|
|
--font-body: 'Bricolage Grotesque', system-ui, sans-serif;
|
|
--font-mono: 'Fragment Mono', 'SF Mono', Consolas, monospace;
|
|
|
|
--bg: #f0fdfa;
|
|
--surface: #ffffff;
|
|
--border: rgba(0, 0, 0, 0.07);
|
|
--text: #134e4a;
|
|
--text-dim: #5f8a85;
|
|
|
|
--primary: #0d9488;
|
|
--primary-dim: rgba(13, 148, 136, 0.08);
|
|
--secondary: #0369a1;
|
|
--tertiary: #d97706;
|
|
--danger: #dc2626;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--bg: #042f2e;
|
|
--surface: #0a3d3a;
|
|
--border: rgba(255, 255, 255, 0.08);
|
|
--text: #ccfbf1;
|
|
--text-dim: #5eead4;
|
|
|
|
--primary: #2dd4bf;
|
|
--primary-dim: rgba(45, 212, 191, 0.14);
|
|
--secondary: #38bdf8;
|
|
--tertiary: #fbbf24;
|
|
--danger: #f87171;
|
|
}
|
|
}
|
|
|
|
/* ── Dark (manual toggle override) ── */
|
|
[data-theme="dark"] {
|
|
--bg: #042f2e;
|
|
--surface: #0a3d3a;
|
|
--border: rgba(255, 255, 255, 0.08);
|
|
--text: #ccfbf1;
|
|
--text-dim: #5eead4;
|
|
--primary: #2dd4bf;
|
|
--primary-dim: rgba(45, 212, 191, 0.14);
|
|
--secondary: #38bdf8;
|
|
--tertiary: #fbbf24;
|
|
--danger: #f87171;
|
|
}
|
|
|
|
/* ============ THEME TOGGLE ============ */
|
|
.theme-toggle {
|
|
position: fixed; top: 16px; right: 16px; z-index: 300;
|
|
width: 36px; height: 36px; border-radius: 8px;
|
|
border: 1px solid var(--border); background: var(--surface);
|
|
color: var(--text-dim); cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 16px; transition: background 0.15s, color 0.15s;
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
|
}
|
|
.theme-toggle:hover { background: var(--surface); color: var(--text); }
|
|
|
|
/* ============ RESET + BASE ============ */
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
background-color: var(--bg);
|
|
background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
|
|
background-size: 24px 24px;
|
|
color: var(--text);
|
|
font-family: var(--font-body);
|
|
padding: 40px;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* ============ ANIMATION ============ */
|
|
@keyframes fadeUp {
|
|
from { opacity: 0; transform: translateY(12px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.animate {
|
|
animation: fadeUp 0.4s ease-out both;
|
|
animation-delay: calc(var(--i, 0) * 0.06s);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-delay: 0ms !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
|
|
/* ============ LAYOUT ============ */
|
|
.container {
|
|
max-width: 1000px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 38px;
|
|
font-weight: 700;
|
|
letter-spacing: -1px;
|
|
margin-bottom: 6px;
|
|
text-wrap: balance;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.description {
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
color: var(--text-dim);
|
|
margin-bottom: 24px;
|
|
max-width: 700px;
|
|
}
|
|
|
|
.description code {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
background: var(--primary-dim);
|
|
color: var(--primary);
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* ============ MERMAID CONTAINER ============ */
|
|
.mermaid-wrap {
|
|
position: relative;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 32px 24px;
|
|
overflow: auto;
|
|
margin-bottom: 24px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.zoom-controls {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
display: flex;
|
|
gap: 2px;
|
|
z-index: 10;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 2px;
|
|
}
|
|
|
|
.zoom-controls button {
|
|
width: 28px;
|
|
height: 28px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.15s ease, color 0.15s ease;
|
|
}
|
|
|
|
.zoom-controls button:hover {
|
|
background: var(--border);
|
|
color: var(--text);
|
|
}
|
|
|
|
.mermaid-wrap { cursor: grab; }
|
|
.mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
|
|
|
|
/* ============ MULTI-DIAGRAM STRUCTURE ============ */
|
|
.diagram-shell {
|
|
position: relative;
|
|
}
|
|
|
|
.diagram-shell__hint {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
margin-bottom: 8px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.mermaid-viewport {
|
|
position: relative;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 300px;
|
|
}
|
|
|
|
.mermaid-canvas {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
}
|
|
|
|
.zoom-label {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
padding: 0 6px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ============ MERMAID SVG OVERRIDES ============ */
|
|
.mermaid .nodeLabel {
|
|
font-family: var(--font-body) !important;
|
|
font-size: 16px !important;
|
|
}
|
|
|
|
.mermaid .edgeLabel {
|
|
font-family: var(--font-mono) !important;
|
|
font-size: 13px !important;
|
|
}
|
|
|
|
.mermaid .node rect,
|
|
.mermaid .node circle,
|
|
.mermaid .node polygon {
|
|
stroke-width: 1.5px !important;
|
|
}
|
|
|
|
.mermaid .edge-pattern-solid {
|
|
stroke-width: 1.5px !important;
|
|
}
|
|
|
|
/* ============ LEGEND ============ */
|
|
.legend {
|
|
display: flex;
|
|
gap: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.legend-swatch {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* ============ CALLOUT ============ */
|
|
.callout {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-left: 3px solid var(--primary);
|
|
border-radius: 0 10px 10px 0;
|
|
padding: 16px 20px;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: var(--text-dim);
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.callout strong { color: var(--text); }
|
|
|
|
.callout code {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
background: var(--primary-dim);
|
|
color: var(--primary);
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* ============ RESPONSIVE ============ */
|
|
@media (max-width: 768px) {
|
|
body { padding: 16px; }
|
|
h1 { font-size: 22px; }
|
|
.mermaid-wrap { padding: 16px 12px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<button class="theme-toggle" id="themeToggle" title="Toggle theme" aria-label="Toggle light/dark theme"></button>
|
|
<script>
|
|
(function() {
|
|
var t = document.getElementById('themeToggle');
|
|
var s = localStorage.getItem('theme');
|
|
var i = s || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
if (s) document.documentElement.setAttribute('data-theme', i);
|
|
t.textContent = i === 'dark' ? '\u2600' : '\u263E';
|
|
t.addEventListener('click', function() {
|
|
var c = document.documentElement.getAttribute('data-theme')
|
|
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
var n = c === 'light' ? 'dark' : 'light';
|
|
document.documentElement.setAttribute('data-theme', n);
|
|
localStorage.setItem('theme', n);
|
|
t.textContent = n === 'dark' ? '\u2600' : '\u263E';
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<div class="container">
|
|
|
|
<h1 class="animate" style="--i:0">CI/CD Pipeline</h1>
|
|
<p class="subtitle animate" style="--i:1">github actions → staging → production</p>
|
|
|
|
<p class="description animate" style="--i:2">
|
|
Every push to <code>main</code> triggers the full pipeline. Tests and linting run in parallel,
|
|
then the build step produces a Docker image. Staging deploys automatically; production requires
|
|
manual approval via a GitHub environment gate.
|
|
</p>
|
|
|
|
<section class="diagram-shell animate" style="--i:3">
|
|
<p class="diagram-shell__hint">
|
|
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
|
|
</p>
|
|
<div class="mermaid-wrap">
|
|
<div class="zoom-controls">
|
|
<button type="button" data-action="zoom-in" title="Zoom in">+</button>
|
|
<button type="button" data-action="zoom-out" title="Zoom out">−</button>
|
|
<button type="button" data-action="zoom-fit" title="Smart fit">↺</button>
|
|
<button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
|
|
<button type="button" data-action="zoom-expand" title="Open full size">⛶</button>
|
|
<span class="zoom-label">Loading...</span>
|
|
</div>
|
|
<div class="mermaid-viewport">
|
|
<div class="mermaid mermaid-canvas"></div>
|
|
</div>
|
|
</div>
|
|
<script type="text/plain" class="diagram-source">
|
|
graph TD
|
|
A[Push to main] --> B{Branch?}
|
|
B -->|main| C[Run Tests]
|
|
B -->|feature| I[Run Tests]
|
|
I --> J[Preview Deploy]
|
|
C --> D[Lint + Type Check]
|
|
C --> E[Unit Tests]
|
|
C --> F[Integration Tests]
|
|
D --> G[Build Docker Image]
|
|
E --> G
|
|
F --> G
|
|
G --> H[Deploy to Staging]
|
|
H --> K{Smoke Tests Pass?}
|
|
K -->|Yes| L[Manual Approval]
|
|
K -->|No| M[Alert + Rollback]
|
|
L --> N[Deploy to Production]
|
|
N --> O[Health Check]
|
|
O --> P[Done]
|
|
</script>
|
|
</section>
|
|
|
|
<div class="legend animate" style="--i:4">
|
|
<div class="legend-item"><div class="legend-swatch" style="background:var(--primary)"></div> Automated step</div>
|
|
<div class="legend-item"><div class="legend-swatch" style="background:var(--tertiary)"></div> Decision gate</div>
|
|
<div class="legend-item"><div class="legend-swatch" style="background:var(--danger)"></div> Failure path</div>
|
|
<div class="legend-item"><div class="legend-swatch" style="background:var(--secondary)"></div> Success</div>
|
|
</div>
|
|
|
|
<div class="callout animate" style="--i:5">
|
|
<strong>ELK layout.</strong> The <code>layout: 'elk'</code> engine provides better node positioning
|
|
for complex graphs — it requires the separate <code>@mermaid-js/layout-elk</code> package (imported above).
|
|
Without it, Mermaid silently falls back to dagre.
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script type="module">
|
|
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
|
|
|
|
const config = {
|
|
fitPadding: 28,
|
|
minHeight: 360,
|
|
maxHeightPx: 960,
|
|
maxHeightVh: 0.84,
|
|
maxInitialZoom: 1.8,
|
|
minZoom: 0.08,
|
|
maxZoom: 6.5,
|
|
zoomStep: 0.14,
|
|
readabilityFloor: 0.58
|
|
};
|
|
|
|
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
|
|
let activeDrag = null;
|
|
|
|
addEventListener('mousemove', (e) => activeDrag?.onMove(e));
|
|
addEventListener('mouseup', () => {
|
|
activeDrag?.onEnd();
|
|
activeDrag = null;
|
|
});
|
|
|
|
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
mermaid.registerLayoutLoaders(elkLayouts);
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
theme: 'base',
|
|
look: 'classic',
|
|
layout: 'elk',
|
|
themeVariables: {
|
|
fontFamily: "'Bricolage Grotesque', system-ui, sans-serif",
|
|
fontSize: '16px',
|
|
primaryColor: isDark ? '#115e59' : '#ccfbf1',
|
|
primaryBorderColor: isDark ? '#2dd4bf' : '#0d9488',
|
|
primaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
|
secondaryColor: isDark ? '#0c4a6e' : '#e0f2fe',
|
|
secondaryBorderColor: isDark ? '#38bdf8' : '#0369a1',
|
|
secondaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
|
tertiaryColor: isDark ? '#2e2618' : '#fffbeb',
|
|
tertiaryBorderColor: isDark ? '#fbbf24' : '#d97706',
|
|
tertiaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
|
lineColor: isDark ? '#5eead4' : '#5f8a85',
|
|
noteBkgColor: isDark ? '#115e59' : '#fefce8',
|
|
noteTextColor: isDark ? '#ccfbf1' : '#134e4a',
|
|
noteBorderColor: isDark ? '#fbbf24' : '#d97706',
|
|
}
|
|
});
|
|
|
|
function initDiagram(shell) {
|
|
const wrap = shell.querySelector('.mermaid-wrap');
|
|
const viewport = shell.querySelector('.mermaid-viewport');
|
|
const canvas = shell.querySelector('.mermaid-canvas');
|
|
const source = shell.querySelector('.diagram-source');
|
|
const label = shell.querySelector('.zoom-label');
|
|
|
|
if (!wrap || !viewport || !canvas || !source || !label) {
|
|
console.error('initDiagram: missing required elements in', shell);
|
|
return;
|
|
}
|
|
|
|
let zoom = 1;
|
|
let fitMode = 'contain';
|
|
let panX = 0;
|
|
let panY = 0;
|
|
let svgW = 0;
|
|
let svgH = 0;
|
|
|
|
let sx = 0;
|
|
let sy = 0;
|
|
let spx = 0;
|
|
let spy = 0;
|
|
|
|
let touchDist = 0;
|
|
let touchCx = 0;
|
|
let touchCy = 0;
|
|
|
|
function constrainPan() {
|
|
const vpW = viewport.clientWidth;
|
|
const vpH = viewport.clientHeight;
|
|
const rW = svgW * zoom;
|
|
const rH = svgH * zoom;
|
|
const pad = config.fitPadding;
|
|
|
|
panX = (rW + pad * 2 <= vpW)
|
|
? (vpW - rW) / 2
|
|
: clamp(panX, vpW - rW - pad, pad);
|
|
panY = (rH + pad * 2 <= vpH)
|
|
? (vpH - rH) / 2
|
|
: clamp(panY, vpH - rH - pad, pad);
|
|
}
|
|
|
|
function applyTransform() {
|
|
const svg = canvas.querySelector('svg');
|
|
if (!svg || !svgW) return;
|
|
|
|
constrainPan();
|
|
svg.style.width = (svgW * zoom) + 'px';
|
|
svg.style.height = (svgH * zoom) + 'px';
|
|
canvas.style.transform = `translate(${panX}px, ${panY}px)`;
|
|
label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;
|
|
}
|
|
|
|
function canPan() {
|
|
const rW = svgW * zoom;
|
|
const rH = svgH * zoom;
|
|
return rW + config.fitPadding * 2 > viewport.clientWidth
|
|
|| rH + config.fitPadding * 2 > viewport.clientHeight;
|
|
}
|
|
|
|
function computeSmartFit() {
|
|
const vpW = viewport.clientWidth;
|
|
const vpH = viewport.clientHeight;
|
|
const aW = Math.max(80, vpW - config.fitPadding * 2);
|
|
const aH = Math.max(80, vpH - config.fitPadding * 2);
|
|
const contain = Math.min(aW / svgW, aH / svgH);
|
|
|
|
let z = contain;
|
|
let mode = 'contain';
|
|
if (contain < config.readabilityFloor) {
|
|
const chartR = svgH / svgW;
|
|
const vpR = vpH / Math.max(vpW, 1);
|
|
if (chartR >= vpR) {
|
|
z = aW / svgW;
|
|
mode = 'width-priority';
|
|
} else {
|
|
z = aH / svgH;
|
|
mode = 'height-priority';
|
|
}
|
|
}
|
|
return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };
|
|
}
|
|
|
|
function fitDiagram() {
|
|
if (!svgW) return;
|
|
const fit = computeSmartFit();
|
|
zoom = fit.zoom;
|
|
fitMode = fit.mode;
|
|
panX = (viewport.clientWidth - svgW * zoom) / 2;
|
|
panY = (viewport.clientHeight - svgH * zoom) / 2;
|
|
applyTransform();
|
|
}
|
|
|
|
function setOneToOne() {
|
|
zoom = clamp(1, config.minZoom, config.maxZoom);
|
|
fitMode = '1:1';
|
|
panX = (viewport.clientWidth - svgW * zoom) / 2;
|
|
panY = (viewport.clientHeight - svgH * zoom) / 2;
|
|
applyTransform();
|
|
}
|
|
|
|
function zoomAround(factor, cx, cy) {
|
|
const next = clamp(zoom * factor, config.minZoom, config.maxZoom);
|
|
const ratio = next / zoom;
|
|
panX = cx - ratio * (cx - panX);
|
|
panY = cy - ratio * (cy - panY);
|
|
zoom = next;
|
|
fitMode = 'custom';
|
|
applyTransform();
|
|
}
|
|
|
|
function readSvgNaturalSize(svg) {
|
|
let w = 0;
|
|
let h = 0;
|
|
if (svg.viewBox?.baseVal?.width > 0) {
|
|
w = svg.viewBox.baseVal.width;
|
|
h = svg.viewBox.baseVal.height;
|
|
}
|
|
if (!w) {
|
|
w = parseFloat(svg.getAttribute('width')) || 0;
|
|
h = parseFloat(svg.getAttribute('height')) || 0;
|
|
}
|
|
if (!w) {
|
|
const b = svg.getBBox();
|
|
w = b.width;
|
|
h = b.height;
|
|
}
|
|
if (!w) {
|
|
const r = svg.getBoundingClientRect();
|
|
w = r.width || 1000;
|
|
h = r.height || 700;
|
|
}
|
|
if (!svg.getAttribute('viewBox')) {
|
|
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
}
|
|
return { w, h };
|
|
}
|
|
|
|
function setAdaptiveHeight() {
|
|
if (!svgW) return;
|
|
const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);
|
|
const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;
|
|
const maxVp = Math.floor(innerHeight * config.maxHeightVh);
|
|
const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));
|
|
wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';
|
|
}
|
|
|
|
function openInNewTab() {
|
|
const svg = canvas.querySelector('svg');
|
|
if (!svg) return;
|
|
|
|
const clone = svg.cloneNode(true);
|
|
clone.style.width = '';
|
|
clone.style.height = '';
|
|
|
|
// Use the same isDark value that was used to render the Mermaid theme
|
|
// This ensures the background matches the baked-in SVG colors
|
|
const bg = isDark ? '#042f2e' : '#f0fdfa';
|
|
|
|
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Diagram</title><style>
|
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
background:${bg};padding:40px;box-sizing:border-box}
|
|
svg{max-width:100%;max-height:90vh;height:auto}
|
|
</style></head><body>${clone.outerHTML}</body></html>`;
|
|
|
|
open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');
|
|
}
|
|
|
|
async function render() {
|
|
try {
|
|
const code = source.textContent.trim();
|
|
if (!code) {
|
|
label.textContent = 'Error: Empty source';
|
|
return;
|
|
}
|
|
|
|
const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
const { svg } = await mermaid.render(id, code);
|
|
canvas.innerHTML = svg;
|
|
|
|
const svgNode = canvas.querySelector('svg');
|
|
if (!svgNode) {
|
|
label.textContent = 'Error: No SVG';
|
|
return;
|
|
}
|
|
|
|
const size = readSvgNaturalSize(svgNode);
|
|
svgW = size.w;
|
|
svgH = size.h;
|
|
|
|
svgNode.removeAttribute('width');
|
|
svgNode.removeAttribute('height');
|
|
svgNode.style.maxWidth = 'none';
|
|
svgNode.style.display = 'block';
|
|
|
|
setAdaptiveHeight();
|
|
fitDiagram();
|
|
} catch (err) {
|
|
console.error('Mermaid render failed:', err);
|
|
label.textContent = 'Error: ' + (err.message || 'Render failed');
|
|
}
|
|
}
|
|
|
|
const actions = {
|
|
'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),
|
|
'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),
|
|
'zoom-fit': fitDiagram,
|
|
'zoom-one': setOneToOne,
|
|
'zoom-expand': openInNewTab
|
|
};
|
|
|
|
Object.entries(actions).forEach(([action, handler]) => {
|
|
wrap.querySelector(`[data-action="${action}"]`)?.addEventListener('click', handler);
|
|
});
|
|
|
|
viewport.addEventListener('dblclick', fitDiagram);
|
|
|
|
viewport.addEventListener('wheel', (e) => {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
const rect = viewport.getBoundingClientRect();
|
|
const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);
|
|
zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);
|
|
return;
|
|
}
|
|
if (canPan()) {
|
|
e.preventDefault();
|
|
panX -= e.deltaX;
|
|
panY -= e.deltaY;
|
|
applyTransform();
|
|
}
|
|
}, { passive: false });
|
|
|
|
viewport.addEventListener('mousedown', (e) => {
|
|
if (e.target.closest('.zoom-controls') || !canPan()) return;
|
|
wrap.classList.add('is-panning');
|
|
sx = e.clientX;
|
|
sy = e.clientY;
|
|
spx = panX;
|
|
spy = panY;
|
|
e.preventDefault();
|
|
|
|
activeDrag = {
|
|
onMove: (ev) => {
|
|
panX = spx + (ev.clientX - sx);
|
|
panY = spy + (ev.clientY - sy);
|
|
applyTransform();
|
|
},
|
|
onEnd: () => {
|
|
wrap.classList.remove('is-panning');
|
|
}
|
|
};
|
|
});
|
|
|
|
viewport.addEventListener('touchstart', (e) => {
|
|
if (e.touches.length === 1) {
|
|
sx = e.touches[0].clientX;
|
|
sy = e.touches[0].clientY;
|
|
spx = panX;
|
|
spy = panY;
|
|
} else if (e.touches.length === 2) {
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
touchDist = Math.sqrt(dx * dx + dy * dy);
|
|
const r = viewport.getBoundingClientRect();
|
|
touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
|
|
touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
|
|
}
|
|
}, { passive: true });
|
|
|
|
viewport.addEventListener('touchmove', (e) => {
|
|
if (e.touches.length === 1 && canPan()) {
|
|
if (touchDist > 0) {
|
|
sx = e.touches[0].clientX;
|
|
sy = e.touches[0].clientY;
|
|
spx = panX;
|
|
spy = panY;
|
|
touchDist = 0;
|
|
}
|
|
e.preventDefault();
|
|
panX = spx + (e.touches[0].clientX - sx);
|
|
panY = spy + (e.touches[0].clientY - sy);
|
|
applyTransform();
|
|
} else if (e.touches.length === 2 && touchDist > 0) {
|
|
e.preventDefault();
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
const d = Math.sqrt(dx * dx + dy * dy);
|
|
zoomAround(d / touchDist, touchCx, touchCy);
|
|
touchDist = d;
|
|
}
|
|
}, { passive: false });
|
|
|
|
new ResizeObserver(() => {
|
|
if (svgW) {
|
|
setAdaptiveHeight();
|
|
fitDiagram();
|
|
}
|
|
}).observe(wrap);
|
|
|
|
render();
|
|
}
|
|
|
|
document.querySelectorAll('.diagram-shell').forEach(initDiagram);
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|