Förbättra sjöjungfrudiagram med Pan/Zoom och export (Svenska (Swedish))

Förbättra sjöjungfrudiagram med Pan/Zoom och export

Friday, 07 November 2025

//

14 minute read

Inledning

npm-paketet finns tillgängligt: Detta genomförande är nu tillgängligt som @mestlylucid/mermaid-enhancements - ett produktionsklart npm-paket. Publicera Mermaid förbättringar som ett npm-paket för detaljer om hur du använder det i dina projekt.

Mermaid är ett fantastiskt verktyg för att skapa diagram från text, men standard rendering kan vara att begränsa för komplexa diagram. Användare kan inte enkelt zooma in för att se detaljer, panorera runt stora diagram, eller exportera dem för dokumentation. I den här artikeln, Jag ska visa dig hur jag förbättrade Mermaid diagram på denna webbplats med interaktiva pan / zoom kontroller, fullscreen ljusbox visning, och export funktionalitet (både PNG- och SVG-format).

Denna implementation är produktionsklar, hanterar mörkt läge växla graciöst, och är motståndskraftig mot Cloudflare Rocket Loader störningar.

Hur det ser ut

Så vad vi går för är detta. En trevlig sida (och popout) sjöjungfru.js display som är typ som GitHubs men bättre. Betyder diagram inte tar upp SCREEENS men är fortfarande easuu att läsa.

mermaid_pan_zoom.png

Problemet

Ut ur lådan, Mermaid diagram har flera begränsningar:

  1. Fast storlek - Stora diagram är antingen avskurna eller krympta för att passa
  2. Ingen interaktivitet - Kan inte zooma in för att se detaljer eller panorera runt
  3. Ingen export - Användare kan inte spara diagram för extern användning
  4. Dålig mobil erfarenhet - Små skärmar gör komplexa diagram oanvändbara
  5. Teman för byte av problem - Diagram re-renderar inte alltid korrekt när man växlar mellan ljus och mörkt läge

Lösningen

Jag har implementerat ett omfattande förbättringssystem som lägger till:

  • Interactive pan/zoom att använda biblioteket svg-pan-zoom
  • Flytande styrknappar för att zooma in/ut, återställa, panorera och exportera
  • Fullskärmsljusbox läge för bättre visning
  • PNG- och SVG-export funktionalitet
  • Automatisk anpassning vid belastning så hela diagrammet är synligt som standard Seing
  • Fullbreddsdisplay ta bort artificiella storleksbegränsningar

Översikt över arkitekturen

Lösningen består av tre huvudkomponenter:

graph TB
    A[mermaid_theme_switch.js] -->|Initializes| B[Mermaid Diagrams]
    B -->|Renders SVG| C[mermaid_enhancements.js]
    C -->|Adds| D[Control Buttons]
    C -->|Initializes| E[svg-pan-zoom]
    D -->|Triggers| F[Pan/Zoom Actions]
    D -->|Triggers| G[Export Functions]
    D -->|Triggers| H[Fullscreen Lightbox]

Genomförande

Installera beroenden

Installera först de nödvändiga npm-paketen:

npm install svg-pan-zoom html-to-image

Dessa bibliotek tillhandahåller följande:

  • svg-pan-zoom - Interactive pan och zoom funktionalitet för SVG element
  • html-to-image - Exportera SVG/PNG-funktionalitet

Modul för kärnförbättring

Den viktigaste förbättringsmodulen (mermaid_enhancements.js) hanterar alla interaktiva funktioner.

Skapa kontrollknappar

Varje diagram får en flytande kontrollpanel med knappar för alla åtgärder:

function createControlButtons(container, diagramId) {
    // Check if controls already exist
    if (container.querySelector('.mermaid-controls')) {
        return;
    }

    const controlsDiv = document.createElement('div');
    controlsDiv.className = 'mermaid-controls';

    const buttons = [
        { icon: 'bx-fullscreen', title: 'Fullscreen', action: 'fullscreen' },
        { icon: 'bx-zoom-in', title: 'Zoom In', action: 'zoomIn' },
        { icon: 'bx-zoom-out', title: 'Zoom Out', action: 'zoomOut' },
        { icon: 'bx-reset', title: 'Reset View', action: 'reset' },
        { icon: 'bx-move', title: 'Pan', action: 'pan' },
        { icon: 'bx-image', title: 'Export as PNG', action: 'exportPng' },
        { icon: 'bx-code-alt', title: 'Export as SVG', action: 'exportSvg' }
    ];

    buttons.forEach(btn => {
        const button = document.createElement('button');
        button.className = `mermaid-control-btn bx ${btn.icon}`;
        button.setAttribute('title', btn.title);
        button.setAttribute('aria-label', btn.title);
        button.setAttribute('data-action', btn.action);
        button.setAttribute('data-diagram-id', diagramId);
        controlsDiv.appendChild(button);
    });

    container.appendChild(controlsDiv);
}

Initiering av Pan/Zoom

Det svg-pan-zoom biblioteket ger smidig, performant interaktion:

function initPanZoom(svgElement, diagramId) {
    // Clean up existing instance if present
    if (panZoomInstances.has(diagramId)) {
        try {
            panZoomInstances.get(diagramId).destroy();
        } catch (e) {
            console.warn('Failed to destroy existing pan-zoom instance:', e);
        }
        panZoomInstances.delete(diagramId);
    }

    try {
        const panZoomInstance = svgPanZoom(svgElement, {
            zoomEnabled: true,
            controlIconsEnabled: false, // We use custom controls
            fit: true,
            center: true,
            minZoom: 0.1,
            maxZoom: 10,
            zoomScaleSensitivity: 0.3,
            dblClickZoomEnabled: true,
            mouseWheelZoomEnabled: true,
            preventMouseEventsDefault: true,
            contain: false
        });

        panZoomInstances.set(diagramId, panZoomInstance);
        return panZoomInstance;
    } catch (error) {
        console.error('Failed to initialize pan-zoom:', error);
        return null;
    }
}

Exportfunktionalitet

Exportsystemet bevarar diagramkvalitet och hanterar både PNG- och SVG-format:

async function exportDiagram(container, format, diagramId) {
    try {
        const svgElement = container.querySelector('svg');
        if (!svgElement) {
            window.showToast && window.showToast('No diagram found to export', 3000, 'error');
            return;
        }

        // Clone the SVG to avoid modifying the original
        const clonedSvg = svgElement.cloneNode(true);

        // Get the viewBox or calculate from bounding box
        let viewBox = clonedSvg.getAttribute('viewBox');
        if (!viewBox) {
            const bbox = svgElement.getBBox();
            viewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`;
            clonedSvg.setAttribute('viewBox', viewBox);
        }

        // Parse viewBox to get dimensions
        const [, , vbWidth, vbHeight] = viewBox.split(' ').map(Number);

        // Set explicit dimensions based on viewBox for proper export
        clonedSvg.setAttribute('width', vbWidth);
        clonedSvg.setAttribute('height', vbHeight);

        // Remove inline styles but keep viewBox
        clonedSvg.removeAttribute('style');
        clonedSvg.style.backgroundColor = 'transparent';
        clonedSvg.style.maxWidth = 'none';

        // Create temporary container
        const tempDiv = document.createElement('div');
        tempDiv.style.position = 'absolute';
        tempDiv.style.left = '-9999px';
        tempDiv.appendChild(clonedSvg);
        document.body.appendChild(tempDiv);

        let dataUrl;
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
        const filename = `mermaid-diagram-${timestamp}`;

        if (format === 'png') {
            dataUrl = await toPng(clonedSvg, {
                backgroundColor: 'white',
                pixelRatio: 2 // Higher quality
            });
            downloadFile(dataUrl, `${filename}.png`);
        } else {
            dataUrl = await toSvg(clonedSvg, {
                backgroundColor: 'transparent'
            });
            downloadFile(dataUrl, `${filename}.svg`);
        }

        // Clean up
        document.body.removeChild(tempDiv);

        window.showToast && window.showToast(`Diagram exported as ${format.toUpperCase()}`, 3000, 'success');
    } catch (error) {
        console.error('Failed to export diagram:', error);
        window.showToast && window.showToast('Failed to export diagram', 3000, 'error');
    }
}

Viktiga exportöverväganden:

  1. Bevara vyruta - Kritisk för att fånga hela diagrammet, inte bara den synliga delen
  2. Beräkna mått - explicit inställd bredd/höjd från viewBox för konsekvent export
  3. Ta bort transformer - Strip pan-zoom transformerar så export visar hela diagrammet
  4. Bakgrundshantering - Vit bakgrund för PNG, transparent för SVG
  5. Hög upplösning - Användning pixelRatio: 2 för skarp PNG-export

Fullskärmsljusbox

Lightboxen ger en uppslukande visningsupplevelse:

function openFullscreenLightbox(container, diagramId) {
    const svgElement = container.querySelector('svg');
    if (!svgElement) return;

    // Create lightbox overlay
    const lightbox = document.createElement('div');
    lightbox.className = 'mermaid-lightbox';
    lightbox.innerHTML = `
        <div class="mermaid-lightbox-content">
            <button class="mermaid-lightbox-close bx bx-x" aria-label="Close"></button>
            <div class="mermaid-lightbox-diagram-wrapper">
                <div class="mermaid-lightbox-diagram"></div>
            </div>
        </div>
    `;

    // Clone and prepare SVG
    const clonedSvg = svgElement.cloneNode(true);
    clonedSvg.removeAttribute('width');
    clonedSvg.removeAttribute('height');
    clonedSvg.style.width = '100%';
    clonedSvg.style.height = '100%';

    const diagramContainer = lightbox.querySelector('.mermaid-lightbox-diagram');
    diagramContainer.appendChild(clonedSvg);

    // Add controls to lightbox
    const wrapper = lightbox.querySelector('.mermaid-lightbox-diagram-wrapper');
    const lightboxDiagramId = `${diagramId}-lightbox`;
    createControlButtons(wrapper, lightboxDiagramId);

    document.body.appendChild(lightbox);

    // Initialize pan-zoom after layout completes
    setTimeout(() => {
        const panZoom = initPanZoom(clonedSvg, lightboxDiagramId);
        if (panZoom) {
            panZoom.resize();
            panZoom.fit();
            panZoom.center();
        }
    }, 100);

    // Close handlers
    const closeLightbox = () => {
        if (panZoomInstances.has(lightboxDiagramId)) {
            try {
                panZoomInstances.get(lightboxDiagramId).destroy();
            } catch (e) {
                console.warn('Failed to destroy lightbox pan-zoom:', e);
            }
            panZoomInstances.delete(lightboxDiagramId);
        }
        lightbox.remove();
    };

    lightbox.querySelector('.mermaid-lightbox-close').addEventListener('click', closeLightbox);
    lightbox.addEventListener('click', (e) => {
        if (e.target === lightbox) closeLightbox();
    });

    // ESC key to close
    const escHandler = (e) => {
        if (e.key === 'Escape') {
            closeLightbox();
            document.removeEventListener('keydown', escHandler);
        }
    };
    document.addEventListener('keydown', escHandler);
}

Huvudsaklig förbättringsfunktion

Detta knyter ihop allt och kallas efter Mermaid gör:

export function enhanceMermaidDiagrams() {
    const diagrams = document.querySelectorAll('.mermaid[data-processed="true"]');

    diagrams.forEach(diagram => {
        const svgElement = diagram.querySelector('svg');
        if (!svgElement) return;

        // CRITICAL: Remove inline max-width constraint that Mermaid adds
        svgElement.style.maxWidth = 'none';

        // Wrap diagram with controls
        const diagramId = wrapDiagramWithControls(diagram);

        // Initialize pan/zoom and auto-fit
        const panZoom = initPanZoom(svgElement, diagramId);
        if (panZoom) {
            // Fit diagram to container by default
            setTimeout(() => {
                panZoom.resize();
                panZoom.fit();
                panZoom.center();
            }, 100);
        }
    });

    // Set up event delegation for control buttons (only once)
    if (!document.body.hasAttribute('data-mermaid-controls-initialized')) {
        document.body.addEventListener('click', handleControlClick);
        document.body.setAttribute('data-mermaid-controls-initialized', 'true');
    }
}

Kritisk korrigering: Sjöjungfrun tillämpar en inline style="max-width: 1020px" till SVG- element, som förhindrar full bredd visning. Ta bort detta är viktigt för korrekt responsivt beteende.

Integrering av temaområden

Temaomkopplaren säkerställer att diagram återsänds korrekt när du växlar mellan ljus och mörka lägen:

import { enhanceMermaidDiagrams } from './mermaid_enhancements';

const loadMermaid = async (theme) => {
    if (!window.mermaid) return;
    try {
        window.mermaid.initialize({
            startOnLoad: false,
            theme,
            themeVariables: {
                background: 'transparent'
            }
        });
        await window.mermaid.run({
            querySelector: elementSelector,
        });

        // Enhance diagrams after rendering completes
        // Use requestAnimationFrame for better timing
        await new Promise(resolve => {
            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    enhanceMermaidDiagrams();
                    resolve();
                });
            });
        });
    } catch (err) {
        console.error('Mermaid render error:', err);
    }
};

Användning requestAnimationFrame två gånger säkerställer att webbläsaren har slutfört målningen SVG innan vi försöker förbättra den.

Cloudflare Rocket Loader-kompatibilitet

Cloudflare's Rocket Loader kan fördröja JavaScript utförande, bryta initiering. Här är den skottsäkra lösningen:

// Wait for all dependencies to load with exponential backoff
function waitForDependencies(maxAttempts = 50) {
    return new Promise((resolve) => {
        let attempts = 0;

        const checkDependencies = () => {
            attempts++;

            const depsReady =
                typeof window.hljs !== 'undefined' &&
                typeof window.mermaid !== 'undefined' &&
                typeof window.Alpine !== 'undefined' &&
                typeof window.htmx !== 'undefined';

            if (depsReady) {
                console.log('All dependencies loaded after', attempts, 'attempts');

                // Start Alpine.js now that it's loaded
                if (window.Alpine && !window.Alpine.version) {
                    try {
                        window.Alpine.start();
                        console.log('Alpine.js started');
                    } catch (err) {
                        console.error('Failed to start Alpine:', err);
                    }
                }

                resolve();
            } else if (attempts >= maxAttempts) {
                console.warn('Timeout waiting for dependencies');
                resolve(); // Continue anyway
            } else {
                // Retry with exponential backoff
                const delay = Math.min(50 * Math.pow(1.2, attempts), 500);
                setTimeout(checkDependencies, delay);
            }
        };

        checkDependencies();
    });
}

// Robust initialization
async function safeInitialize() {
    try {
        await waitForDependencies();

        if (document.readyState === 'loading') {
            await new Promise(resolve => {
                document.addEventListener('DOMContentLoaded', resolve, { once: true });
            });
        }

        await initializePage();
    } catch (err) {
        console.error('Failed to initialize page:', err);
        // Retry once after delay
        setTimeout(() => {
            initializePage().catch(e => console.error('Retry failed:', e));
        }, 1000);
    }
}

safeInitialize();

Se också till att ditt huvudskript har data-cfasync="false" attribut för att utesluta den från Rocket Loader:

<script src="~/js/dist/main.js" type="module" asp-append-version="true" data-cfasync="false"></script>

Styling med tailwind/DaisyUI

CSS använder Tailwind verktygsklasser och anpassad styling för lack:

/* Mermaid diagram wrapper */
.mermaid-wrapper {
    @apply relative rounded-lg overflow-hidden w-full;
    margin: 1rem 0;
}

.mermaid-wrapper .mermaid {
    @apply m-0 w-full;
    min-height: 500px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
}

.mermaid-wrapper .mermaid svg {
    width: 100% !important;
    height: auto !important;
    min-height: 450px;
}

/* Control buttons */
.mermaid-controls {
    @apply absolute top-2 right-2 flex gap-1 z-10;
    background: rgba(255, 255, 255, 0.9);
    border-radius: 0.5rem;
    padding: 0.25rem;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.dark .mermaid-controls {
    background: rgba(31, 41, 55, 0.95);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

.mermaid-control-btn {
    @apply p-2 rounded cursor-pointer transition-all duration-200;
    background: transparent;
    border: none;
    color: #4b5563;
    font-size: 1.25rem;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
}

.mermaid-control-btn:hover {
    background: rgba(37, 99, 235, 0.1);
    color: #2563eb;
    transform: scale(1.1);
}

.dark .mermaid-control-btn {
    color: #9ca3af;
}

.dark .mermaid-control-btn:hover {
    background: rgba(55, 65, 81, 0.8);
    color: #60a5fa;
}

/* Lightbox */
.mermaid-lightbox {
    @apply fixed inset-0 z-50 flex items-center justify-center;
    background: rgba(0, 0, 0, 0.85);
    backdrop-filter: blur(4px);
    animation: fadeIn 0.2s ease-out;
}

.dark .mermaid-lightbox {
    background: rgba(0, 0, 0, 0.95);
}

.mermaid-lightbox-content {
    @apply relative w-11/12 h-5/6 bg-white rounded-lg shadow-2xl;
    max-width: 1400px;
}

.dark .mermaid-lightbox-content {
    @apply bg-gray-800;
}

.mermaid-lightbox-close {
    @apply absolute top-4 right-4 z-10 p-2 rounded-full cursor-pointer transition-all;
    background: rgba(0, 0, 0, 0.5);
    border: none;
    color: white;
    font-size: 2rem;
    width: 3rem;
    height: 3rem;
    display: flex;
    align-items: center;
    justify-content: center;
}

.mermaid-lightbox-close:hover {
    background: rgba(220, 38, 38, 0.8);
    transform: scale(1.1);
}

@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

Testning och felsökning

Här är hur man verifierar allt fungerar:

Konsolutmatning

Med rätt initiering, bör du se:

All dependencies loaded after 1 attempts
Alpine.js started
Highlight.js copy plugin registered
Highlight.js initialized on page load
Mermaid initialized on page load
Document is ready - all initializations complete
HTMX event listener registered successfully

HTMX Dynamiskt innehåll

Efter HTMX-byten bör du se:

HTMX afterSettle triggered for: contentcontainer
Highlight.js applied after HTMX swap
Mermaid initialized
Mermaid applied after HTMX swap
HTMX afterSettle complete for: contentcontainer

Checklista för testning

  • Diagram ger vid första sidbelastningen
  • Pan / zoom kontroller arbete
  • Fullskärmsljusbox öppnas och stängs (X-knappen, klicka utanför, ESC-tangenten)
  • PNG export fångar hela diagrammet (inte bara hörn)
  • SVG export bevarar vektorer
  • Fungerar efter HTMX innehållsbyte
  • Tema byta re-renders diagram korrekt
  • Mobil respons (kontrollerna förblir synliga, diagramskala)
  • Mörk stil gäller korrekt
  • Tangentbordstillgänglighet (tab to controls, ange för att aktivera)

Prestandaöverväganden

  1. Lasig initiering - Bara förbättra diagram som finns på sidan
  2. Rensning av instanser - Förstör pan-zoom fall när diagram tas bort
  3. Delegering av evenemang - Enklickslyssnare hanterar alla styrknappar
  4. begäranAnimationFrame - Bättre timing än godtyckliga setTimeout-värden
  5. Avslöjad export - Förhindra snabbbrand export klick

Browser-kompatibilitet

Testad och arbeta på:

  • Krom/Edge 90+
  • Firefox 88+ Ordförande
  • Safari 14+
  • Mobilwebbläsare (iOS Safari, Chrome Mobile)

IE11 stöds inte Tack vare moderna JavaScript-funktioner (konst, pilfunktioner, async/await, requestAnimationFrame).

Slutsatser

Denna omfattande förbättring omvandlar statiska Mermaid diagram till interaktiva, exporterbara visualiseringar. Implementationen är produktionsklar, motståndskraftig mot kant fall, och ger en utmärkt användarupplevelse.

Nyckelutflykter:

  • Ta bort sjöjungfruns inline max-width begränsning för diagram med full bredd
  • Bevara vyruta vid export för att fånga hela diagrammet
  • Använd begäranAnimationFrame för timing i stället för godtyckliga förseningar
  • Hantera Cloudflare Rocket Loader med beroendekontroll och försök logik
  • Tillhandahåll flera slutmetoder för ljusbox (knapp, klicka utanför, ESC)
  • Använd händelsedelegation för prestanda med många diagram

Använda npm- paketet

Istället för att kopiera kod, kan du nu installera denna funktionalitet som ett npm-paket:

npm install @mostlylucid/mermaid-enhancements
import { init } from '@mostlylucid/mermaid-enhancements';
import '@mostlylucid/mermaid-enhancements/styles.css';

await init();

Se också Publicera Mermaid förbättringar som ett npm-paket för fullständig dokumentation, exempel på ramintegration och avancerade konfigurationsalternativ.

Den fullständiga källkoden finns också tillgänglig i den här bloggens arkiv på Mostlylucid/src/js/mermaid_enhancements.js och som ett open-source npm-paket på Mest lucidweb/mestadlylucid-mammaid.

Exempeldiagram

Här är ett komplext exempel som visar arkitekturen i bloggens innehållssystem:

graph TB
    subgraph Client["Client Browser"]
        A[User Request] -->|HTMX| B[Blog Controller]
        B -->|Cache Miss| C[Blog Service]
        C -->|File Mode| D[Markdown Service]
        C -->|DB Mode| E[EF Core Context]
        D -->|Parse| F[Markdig Pipeline]
        F -->|Render| G[HTML + Mermaid]
        E -->|Query| H[PostgreSQL]
        H -->|Full-Text Search| I[GIN Index]
        G -->|Enhance| J[mermaid_enhancements.js]
        J -->|Initialize| K[svg-pan-zoom]
        J -->|Add| L[Control Buttons]
        L -->|Export| M[html-to-image]
    end

    subgraph Background["Background Services"]
        N[File Watcher] -->|Change Detected| O[Saves to DB]
        O -->|Trigger| P[Translation Service]
        P -->|Batch| Q[EasyNMT API]
        Q -->|12 Languages| R[Translated Files]
    end 

    style A stroke:#22c55e,stroke-width:3px,color:#4ade80
    style G stroke:#3b82f6,stroke-width:3px,color:#60a5fa
    style J stroke:#f59e0b,stroke-width:3px,color:#f59e0b
    style K stroke:#ec4899,stroke-width:3px,color:#ec4899
    style M stroke:#8b5cf6,stroke-width:3px,color:#8b5cf6

Försök att klicka på kontrollerna på diagrammet ovan!

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.