Back to "Améliorer les diagrammes de sirène avec Pan/Zoom et Export"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

DaisyUI Javascript Mermaid SVG Tailwind

Améliorer les diagrammes de sirène avec Pan/Zoom et Export

Friday, 07 November 2025

Présentation

npm Package Disponible : Cette mise en œuvre est désormais disponible en tant que @mostlylucide/mermaid-enhancements - un pack npm prêt à la production. Voir Édition Sirène Améliorations en tant que paquet npm pour plus de détails sur la façon de l'utiliser dans vos projets.

Sirène est un outil fantastique pour créer des diagrammes à partir de texte, mais le rendu par défaut peut être limité pour des diagrammes complexes. Les utilisateurs ne peuvent pas facilement zoomer pour voir les détails, balayer autour de grands diagrammes, ou les exporter pour la documentation. Dans cet article, je vais vous montrer comment j'ai amélioré les diagrammes Sirène sur ce site avec des commandes panoramiques/zoom interactives, visionnement de la boîte à lumière plein écran et fonctionnalité d'exportation (formats PNG et SVG).

Cette implémentation est prête à la production, gère les commutations en mode sombre gracieusement, et est résiliente aux interférences de la chargeuse de Rocket Cloudflare.

Qu'est-ce que c'est ?

Donc ce que nous allons pour est ceci. Un bel affichage en page (et popout) sirmaid.js qui est un peu comme celui de GitHub mais mieux. signifie que les diagrammes ne prennent pas SCREENS mais sont encore easuu à lire.

mermaid_pan_zoom.png

Le problème

En dehors de la boîte, les diagrammes de Sirène ont plusieurs limites:

  1. Taille fixe - Les grands diagrammes sont coupés ou rétrécis pour s'adapter
  2. Pas d'interactivité - Impossible de zoomer pour voir les détails ou faire le tour
  3. Pas d'exportation - Les utilisateurs ne peuvent pas enregistrer de diagrammes pour une utilisation externe
  4. Mauvaise expérience en matière de téléphonie mobile - Les petits écrans rendent les diagrammes complexes inutilisables
  5. Questions relatives au changement de thème - Les diagrammes ne se relaient pas toujours correctement lorsque l'on passe d'un mode lumineux à un mode sombre

La solution

J'ai mis en place un système d'amélioration complet qui ajoute:

  • Pan/zoom interactif utilisant la bibliothèque svg-pan-zoom
  • Boutons de commande flottants pour zoomer, réinitialiser, basculer et exporter
  • Boîte à lumière plein écran mode pour une meilleure visualisation
  • Exportation de PNG et de SVG fonctionnalité
  • Auto-ajustement à la charge donc tout le diagramme est visible par défaut Voir
  • Affichage de la largeur complète Enlever les contraintes de taille artificielles

Vue d'ensemble de l'architecture

La solution se compose de trois éléments principaux:

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]

Mise en œuvre

Installation des dépendances

Tout d'abord, installez les paquets npm requis:

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

Ces bibliothèques fournissent :

  • svg-pan-zoom - Fonctions interactives de panoramique et de zoom pour les éléments SVG
  • html-to-image - Exporter la fonctionnalité SVG/PNG

Module d'amélioration de base

Le principal module d'amélioration (mermaid_enhancements.js) gère toutes les fonctionnalités interactives.

Création de boutons de contrôle

Chaque diagramme reçoit un panneau de commande flottant avec des boutons pour toutes les actions :

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

Initialisation Pan/Zoom

La bibliothèque svg-pan-zoom fournit une interaction fluide et performante:

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

Fonctionnalité de l'exportation

Le système d'exportation préserve la qualité des diagrammes et gère les formats PNG et SVG :

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

Principales considérations relatives à l'exportation:

  1. Préserver la fenêtre - Critique pour capturer l'ensemble du diagramme, pas seulement la partie visible
  2. Calculer les dimensions - Réglez explicitement la largeur/hauteur de viewBox pour une exportation cohérente
  3. Supprimer les transformations - Strip pan-zoom transforme ainsi l'exportation montre le diagramme complet
  4. Gestion de l ' arrière-plan - Fond blanc pour PNG, transparent pour SVG
  5. Haute résolution - Utilisation pixelRatio: 2 pour les exportations nettes de PNG

Boîte à lumière plein écran

La lightbox offre une expérience de visionnement immersive :

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

Fonction principale d'amélioration

Cela lie tout ensemble et est appelé après Sirène rend:

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

Correction critique: Sirène applique une ligne style="max-width: 1020px" à des éléments SVG, ce qui empêche l'affichage de la largeur complète.

Intégration du thème

Le commutateur de thème assure la remise des diagrammes correctement lors de la commutation entre les modes lumineux et sombres:

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

Utilisation requestAnimationFrame deux fois s'assure que le navigateur a terminé la peinture SVG avant que nous essayons de l'améliorer.

Compatibilité du chargeur de fusées Cloudflare

La Rocket Loader de Cloudflare peut retarder l'exécution JavaScript, brisant l'initialisation. Voici la solution pare-balles :

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

Assurez-vous également que votre script principal a le data-cfasync="false" attribut pour l'exclure de Rocket Loader:

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

Styling avec vent de queue/DaisyUI

Le CSS utilise des classes d'utilitaire Tailwind et un style personnalisé pour le polissage :

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

Essais et débogage

Voici comment vérifier que tout fonctionne :

Sortie de la console

Avec l'initialisation appropriée, vous devriez voir:

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

Contenu dynamique HTMX

Après les swaps HTMX, vous devriez voir:

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

Liste de contrôle des essais

  • Diagrammes rendus sur le chargement initial de la page
  • Travail des commandes Pan/zoom
  • Boîte à lumière plein écran s'ouvre et se ferme (bouton X, cliquez à l'extérieur, touche ESC)
  • L'exportation de PNG capture le diagramme complet (pas seulement le coin)
  • L'exportation SVG préserve les vecteurs
  • Fonctionne après l'échange de contenu HTMX
  • Diagrammes de re-retendeurs de changement de thème correctement
  • Réactivité mobile (les commandes restent visibles, l'échelle des diagrammes)
  • Le style en mode sombre s'applique correctement
  • Accessibilité du clavier (onglet aux commandes, entrée pour activer)

Considérations de performance

  1. Initialisation paresseuse - Enrichir uniquement les diagrammes qui existent sur la page
  2. Nettoyage d'instances - Détruire les instances pan-zoom lorsque les diagrammes sont supprimés
  3. Délégation événementielle - L'auditeur à simple clic gère tous les boutons de contrôle
  4. requestAnimationFrame - Meilleur timing que les valeurs de setTimeout arbitraires
  5. Exportations débloquées - Prévenir les clics d'exportation rapides

Compatibilité du navigateur

Testé et travaillé sur:

  • Chrome/Edge 90+
  • Firefox 88+
  • Safari 14+
  • Navigateurs mobiles (iOS Safari, Chrome Mobile)

IE11 n'est pas pris en charge en raison des fonctionnalités modernes de JavaScript (const, fonctions de flèche, async/attendu, requestAnimationFrame).

Le présent règlement entre en vigueur le vingtième jour suivant celui de sa publication au Journal officiel de l'Union européenne.

Cette amélioration complète transforme les diagrammes de Sirène statique en visualisations interactives exportables. L'implémentation est prête à la production, résiliente aux boîtiers de bord, et fournit une excellente expérience utilisateur.

Prises à emporter clés:

  • Supprimer l'inline de Sirmaid max-width contrainte pour les diagrammes de pleine largeur
  • Préserver viewBox lors de l'exportation pour capturer le diagramme entier
  • Utiliser requestAnimationFrame pour le timing au lieu de retards arbitraires
  • Poignez Cloudflare Rocket Loader avec vérification de la dépendance et réessayer la logique
  • Fournir plusieurs méthodes de fermeture pour la lightbox (bouton, clic extérieur, ESC)
  • Utiliser la délégation d'événement pour la performance avec de nombreux diagrammes

Utilisation du paquet npm

Plutôt que de copier le code, vous pouvez maintenant installer cette fonctionnalité comme un paquet npm:

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

await init();

Voir Édition Sirène Améliorations en tant que paquet npm pour la documentation complète, les exemples d'intégration du cadre et les options de configuration avancées.

Le code source complet est également disponible dans le dépôt de ce blog à Mostlylucid/src/js/mermaid_enhancements.js et en tant que paquet npm open-source à principalementlucideweb/mermaid le pluslylucide.

Exemple de diagramme

Voici un exemple complexe montrant l'architecture du système de contenu de ce 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

Essayez de cliquer sur les commandes du diagramme ci-dessus!

logo

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