Meerminnendiagrammen verbeteren met Pan/Zoom en Export (Nederlands (Dutch))

Meerminnendiagrammen verbeteren met Pan/Zoom en Export

Friday, 07 November 2025

//

14 minute read

Inleiding

npm-pakket beschikbaar: Deze uitvoering is nu beschikbaar als @mostlylucid/mermaid-enhancements - een production-ready npm pakket. Publishing Mermaid Enhancements as an npm Package voor details over het gebruik ervan in uw projecten.

Mermaid is een fantastisch hulpmiddel voor het maken van diagrammen van tekst, maar de standaard rendering kan worden beperkt voor complexe diagrammen. Gebruikers kunnen niet gemakkelijk inzoomen om details te zien, om grote diagrammen heen te draaien of ze te exporteren voor documentatie. In dit artikel zal ik u laten zien hoe ik Mermaid diagrammen op deze site heb verbeterd met interactieve pan/zoom controles, fullscreen lightbox viewing en exportfunctionaliteit (zowel PNG als SVG formaten).

Deze implementatie is productie-klaar, zorgt voor een elegante overstap in de donkere modus en is bestand tegen storing in de Cloudflare Rocket Loader.

Hoe het eruit ziet.

Dus wat we gaan voor is dit. Een mooie in pagina (en popout) zeemeermin.js display dat is een beetje als GitHub's maar beter. Betekent dat diagrammen niet nemen Screens maar zijn nog steeds easuu te lezen.

mermaid_pan_zoom.png

Het probleem

Uit de doos, Mermaid diagrammen hebben verschillende beperkingen:

  1. Vaste grootte - Grote diagrammen worden ofwel afgesneden of gekrompen om te passen
  2. Geen interactiviteit - Kan niet inzoomen om details of pan rond te zien
  3. Geen uitvoer - Gebruikers kunnen geen diagrammen opslaan voor extern gebruik
  4. Slechte mobiele ervaring - Kleine schermen maken complexe diagrammen onbruikbaar
  5. Themawisselproblemen - Diagram's renderen niet altijd correct bij het schakelen tussen licht en donker

De oplossing

Ik heb een uitgebreid enhancement systeem geïmplementeerd dat toevoegt:

  • Interactieve pan/zoom gebruik makend van de svg-pan-zoom-bibliotheek
  • Drijvende bedieningsknoppen voor in/uitzoomen, resetten, schakelen en exporteren
  • Lichtbak met volledig scherm modus voor een betere weergave
  • PNG- en SVG-export functionaliteit
  • Automatisch bij laden dus het hele diagram is standaard zichtbaar Seeing
  • Volledige breedte-display het verwijderen van kunstmatige maatbeperkingen

Overzicht architectuur

De oplossing bestaat uit drie hoofdcomponenten:

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]

Uitvoering

Afhankelijkheden installeren

Installeer eerst de vereiste npm-pakketten:

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

Deze bibliotheken bieden:

  • svg-pan-zoom - Interactieve pan- en zoomfunctionaliteit voor SVG-elementen
  • html-to-image - Export SVG/PNG functionaliteit

Kernvergrotingsmodule

De belangrijkste module voor verbetering (mermaid_enhancements.js) behandelt alle interactieve functies.

Controleknoppen aanmaken

Elk diagram krijgt een drijvend bedieningspaneel met knoppen voor alle acties:

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);
}

Pan/Zoom initialiseren

De svg-pan-zoom bibliotheek biedt een soepele, performante interactie:

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;
    }
}

Exportfunctionaliteit

Het exportsysteem behoudt de diagramkwaliteit en behandelt zowel PNG- als SVG-formaten:

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');
    }
}

Belangrijkste uitvoeroverwegingen:

  1. Weergavebox behouden - Kritisch voor het vastleggen van het gehele diagram, niet alleen het zichtbare gedeelte
  2. Afmetingen berekenen - Expliciet de breedte/hoogte van viewBox instellen voor consistente export
  3. Transformaties verwijderen - Strip pan-zoom transformeert zodat export het volledige diagram laat zien
  4. Achtergrondafhandeling - Witte achtergrond voor PNG, transparant voor SVG
  5. Hoge resolutie - Gebruik pixelRatio: 2 voor scherpe PNG-uitvoer

Volledig scherm Lightbox

De lightbox biedt een meeslepende kijkervaring:

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);
}

Belangrijkste verbeteringsfunctie

Dit verbindt alles en wordt geroepen nadat Mermaid:

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');
    }
}

Kritische fix: Mermaid past een inline toe style="max-width: 1020px" naar SVG-elementen, die volledige weergave voorkomt. Verwijderen is essentieel voor goed responsief gedrag.

Themaintegratie

De thema switcher zorgt ervoor dat diagrammen correct opnieuw renderen bij het schakelen tussen licht en donker modi:

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);
    }
};

Gebruik requestAnimationFrame tweemaal zorgt ervoor dat de browser het schilderij van de SVG heeft voltooid voordat we het proberen te verbeteren.

Compatibiliteit van Cloudflare Raketlader

De Rocket Loader van Cloudflare kan de uitvoering van JavaScript vertragen en de initialisatie verbreken. Hier is de kogelvrije oplossing:

// 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();

Zorg er ook voor dat uw hoofdscript de data-cfasync="false" attribuut om het uit te sluiten van Raket Loader:

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

Styling met staartwind/DaisyUI

De CSS maakt gebruik van Tailwind utility classes en aangepaste styling voor polijsten:

/* 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; }
}

Testen en debuggen

Hier is hoe om alles te controleren werkt:

Console-uitvoer

Met de juiste initialisatie, moet je zien:

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 dynamische inhoud

Na HTMX swaps, moet u zien:

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

Controlelijst testen

  • Diagramweergave bij de initiële paginabelasting
  • Werk van pan/zoombesturingen
  • Volledig scherm lichtbak opent en sluit (X-knop, klik buiten, ESC-toets)
  • PNG export vangt volledig diagram (niet alleen hoek)
  • SVG export bewaart vectoren
  • Werkt na HTMX content swap
  • Thema wisselen van her-renders diagrammen correct
  • Mobiel responsief (controles blijven zichtbaar, diagrammen schaal)
  • Donkere modus styling correct van toepassing
  • Toetsenbord toegankelijkheid (tab aan controles, invoeren om te activeren)

Prestatieoverwegingen

  1. Luie initialisatie - Verbeter alleen diagrammen die bestaan op de pagina
  2. Instance cleanup - Vernietig pan-zoom instanties wanneer diagrammen worden verwijderd
  3. Delegatie van evenementen - Single click luisteraar behandelt alle bedieningsknoppen
  4. verzoekAnimatieframe - Betere timing dan willekeurige setTimeout waarden
  5. Gedebounceerde uitvoer - Voorkom snelle-brand export klikken

Compatibiliteit van de browser

Getest en aan het werk:

  • Chrome/Edge 90+
  • Firefox 88+
  • Safari 14+
  • Mobiele browsers (iOS Safari, Chrome Mobile)

IE11 wordt niet ondersteund vanwege moderne JavaScript functies (const, pijl functies, async / wacht, verzoekAnimatieFrame).

Conclusie

Deze uitgebreide verbetering transformeert statische Mermaid diagrammen in interactieve, exporteerbare visualisaties. De implementatie is productie-klaar, veerkrachtig aan rand gevallen, en biedt een uitstekende gebruikerservaring.

Belangrijkste afhaalmaaltijden:

  • Inline van Mermaid verwijderen max-width beperking voor diagrammen met volledige breedte
  • Behoud viewBox bij exporteren om het gehele diagram vast te leggen
  • Gebruik verzoekAnimatieFrame voor timing in plaats van willekeurige vertragingen
  • Handle Cloudflare Raket Loader met afhankelijkheid controleren en opnieuw proberen logica
  • Bied meerdere sluitmethodes voor lichtbak (knop, klik buiten, ESC)
  • Gebruik evenement delegatie voor prestaties met vele diagrammen

Het npm-pakket gebruiken

In plaats van het kopiëren van code, kunt u nu installeren deze functionaliteit als een npm pakket:

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

await init();

Zie Publishing Mermaid Enhancements as an npm Package voor volledige documentatie, kader integratie voorbeelden, en geavanceerde configuratie opties.

De volledige broncode is ook beschikbaar in de repository van deze blog op Mostlylucid/src/js/mermaid_enhancements.js en als een open-source npm pakket op meestal lucidweb/meestal lucid-meermin.

Voorbeelddiagram

Hier is een complex voorbeeld van de architectuur van het inhoudssysteem van deze blog:

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

Probeer te klikken op de knoppen op het diagram hierboven!

Finding related posts...
logo

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