Back to "Merenneitokaavioiden parantaminen Pan/Zoomin ja Exportin avulla"

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

Merenneitokaavioiden parantaminen Pan/Zoomin ja Exportin avulla

Friday, 07 November 2025

Johdanto

npm Paketti saatavilla: Tämä täytäntöönpano on nyt saatavilla @mostlylucid/mermaid-enhancements - tuotantovalmiin npm-paketin. Merenneidon parannusten julkaiseminen npm-pakettina Tarkempia tietoja siitä, miten sitä käytetään projekteissasi.

Merenneito on loistava työkalu kuvien luomiseen tekstistä, mutta oletusrenderointi voi rajoittaa monimutkaisia kaavioita. Käyttäjät eivät voi helposti zoomata nähdäkseen yksityiskohtia, kiertääkseen suuria kaavioita tai viedäkseen niitä dokumentointiin. Tässä artikkelissa näytän, kuinka parantelin merenneitokaavioita tällä sivustolla vuorovaikutteisilla pan/zoom-ohjaimilla, koko näytön valolaatikon katselulla ja vientitoiminnoilla (sekä PNG- että SVG-muodossa).

Tämä toteutus on tuotantovalmis, käsittelee tummaa tilaa vaihtamalla sulavasti ja kestää Cloudflare Rocket Loader -häirintää.

Miltä se näyttää?

Tämä on siis hieno sivu (ja ponnahduslauta) merenneito.js-näytös, joka on tavallaan kuin GitHubin, mutta parempi. Se tarkoittaa, että kaaviot eivät kestä SCREENSiä, mutta ovat silti helppolukuisia.

mermaid_pan_zoom.png

Ongelma

Merenneidon kaavioissa on laatikosta useita rajoituksia:

  1. Kiinteä koko - Suuret diagrammit joko leikataan pois tai kutistetaan sopivaksi
  2. Ei vuorovaikutusta - Ei voi zoomata nähdäkseen yksityiskohtia tai kiertääkseen
  3. Ei vientiä - Käyttäjät eivät voi tallentaa kaavioita ulkoiseen käyttöön
  4. Huono mobiilikokemus - Pienet näytöt tekevät monimutkaisista kaavioista käyttökelvottomia
  5. Aiheen vaihtaminen - Kaaviot eivät aina renderöi oikein vaihtaessaan valo- ja pimeätilaa

Ratkaisu

Olen toteuttanut kattavan tehostamisjärjestelmän, joka lisää:

  • Interaktiivinen pan/zoom svg-pan-zoom-kirjaston käyttö
  • Kelluvat ohjauspainikkeet zoom in/out, reset, pantoggle ja export
  • Kokonäytön valolaatikko Moodi parempaan katseluun
  • PNG:n ja SVG:n vienti toiminnallisuus
  • Automaattiasennettu ladattavaksi Näin koko kaavio näkyy oletuksena
  • Täyden leveyden näyttö keinotekoisten kokorajoitusten poistaminen

Arkkitehtuurin yleiskatsaus

Ratkaisu koostuu kolmesta pääosasta:

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]

Täytäntöönpano

Asennetaan riippuvuussuhteita

Asenna ensin vaaditut npm-pakkaukset:

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

Nämä kirjastot tarjoavat:

  • svg-pan-zoom - SVG-elementtien interaktiivinen pannu- ja zoomaustoiminto
  • html-to-image - Vie SVG/PNG-toiminnot

Ytimen lisälaitemoduuli

Tärkein tehostamismoduuli (mermaid_enhancements.js) käsittelee kaikkia vuorovaikutteisia ominaisuuksia.

Ohjauspainikkeiden luominen

Jokaisessa kaaviossa on kelluva ohjauspaneeli, jossa on painikkeet kaikkia toimintoja varten:

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

Alustetaan Pan/Zoom

svg-pan-zoom-kirjasto tarjoaa sujuvaa, performanssista vuorovaikutusta:

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

Vie Toiminnallisuus

Vientijärjestelmä säilyttää kaavion laadun ja käsittelee sekä PNG- että SVG-formaatteja:

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

Tärkeimmät vientinäkökohdat:

  1. Säilytä näkymälaatikko - Kriittinen koko kaavion kuvaamiseen, ei vain näkyvään osaan
  2. Laske mitat - Aseta leveys/korkeus näkyvältä Boxista johdonmukaiselle viennille
  3. Poista muunnelmat - Strip pan-zoom muuntuu niin vienti näyttää koko kaavion
  4. Taustan käsittely - Valkoinen tausta PNG:lle, läpinäkyvä SVG:lle
  5. Korkea resoluutio - Käytä pixelRatio: 2 PNG:n terävä vienti

Koko näytön Lightbox

Valolaatikko tarjoaa upporikkaan katselukokemuksen:

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

Tärkein tehostamistoiminto

Tämä yhdistää kaiken, ja sitä kutsutaan sen jälkeen, kun merenneito tekee:

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

Kriittinen korjaus: Merenneito hakee inlineä style="max-width: 1020px" SVG-elementteihin, jotka estävät täyden leveyden näyttämisen. Tämän poistaminen on olennaista oikean reagoivan käyttäytymisen kannalta.

Aihepiirin kotouttaminen

Teemanvaihtaja varmistaa, että kaaviot renderöidään oikein vaihdettaessa valo- ja pimeätiloja:

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

Käyttäminen requestAnimationFrame Kaksi kertaa varmistaa, että selain on valmis maalaamaan SVG:n ennen kuin yritämme parantaa sitä.

Cloudflare Rocket Loader -yhteensopivuus

Cloudflaren Rocket Loader voi viivästyttää JavaScript-toteutusta rikkoen alustuksen. Tässä luodinkestävä ratkaisu:

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

Varmista myös, että pääkirjoituksessasi on data-cfasync="false" Ominaisuus, joka sulkee sen pois Rocket Loaderista:

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

Sisustus myötätuulella/DaisyUI:lla

CSS käyttää kiillotuksessa Tailwindin apuohjelmia ja muokattua muotoilua:

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

Testaus ja vianetsintä

Näin varmistat, että kaikki toimii:

Lohduta tuotosta

Oikealla alustamisella näet:

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 Dynaaminen sisältö

HTMX-swapien jälkeen näet:

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

Testataan tarkistuslistaa

  • Kaaviot renderöidään alkusivun latauksessa
  • Pan/zoom-ohjainten työ
  • Koko näytön valolaatikko aukeaa ja sulkeutuu (X-painiketta, klikkaa ulkoa, ESC-näppäintä)
  • PNG-vienti mittaa koko kaavion (ei vain nurkassa)
  • SVG:n vienti säilyttää vektorit
  • Toimii HTMX-sisältöjen vaihdon jälkeen
  • Aiheen vaihtaminen renderoi kaavioita oikein
  • Liikkuva reagoiva (ohjaimet pysyvät näkyvillä, kaaviot asteikolla)
  • Tumman tilan muotoilu oikein
  • Näppäimistön saavutettavuus (ohjaimet päälle, syötä aktivoitavaksi)

Suorituskykyä koskevia huomioita

  1. Laiska alustaminen - Paranna vain sivulla olevia kaavioita
  2. Oikeuslaitoksen siivous - Tuhoa panzoom-tapaukset, kun diagrammit poistetaan
  3. Tapahtumavaltuuskunta - Yhden napsautuksen kuuntelija käsittelee kaikkia hallintanappeja
  4. anomusAnimationFrame - Parempi ajoitus kuin mielivaltaiset arvot
  5. Todettu vienti - Ennaltaehkäise pikatulituksen vientinapsahduksia

Selainyhteensopivuus

Testattu ja työn alla:

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

IE11:tä ei tueta johtuen modernista JavaScript-ominaisuuksista (cont, nunnatoiminnot, asunc/await, requestAnimationFrame).

Päätelmät

Tämä kattava lisälaite muuttaa staattiset Merenneidon kaaviot vuorovaikutteisiksi, vientikelpoisiksi visualisoinniksi. Toteutus on tuotantovalmis, kestävä ja tarjoaa erinomaisen käyttökokemuksen.

Avainruokia:

  • Poista merenneidon inline max-width rajoite täyden leveyden kaavioille
  • Säilytä näkymäBox, kun viet koko kaavion kaappaamiseen
  • Käytä pyyntöäAnimationFrame ajoitukseen mielivaltaisten viivästysten sijaan
  • Käsittele Cloudflare Rocket Loaderia riippuvuustarkistuksella ja kokeile uudelleen logiikkaa
  • Tarjoa valolaatikolle useita tiiviitä menetelmiä (nappula, napsauta ulkoa, ESC)
  • Käytä tapahtumavaltuuskuntaa suorituksiin monilla kaavioilla

Npm-paketin käyttö

Sen sijaan, että kopioisit koodia, voit nyt asentaa tämän toiminnon npm-pakettina:

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

await init();

Katso Merenneidon parannusten julkaiseminen npm-pakettina täydelliseen dokumentointiin, puiteintegrointiesimerkkeihin ja kehittyneisiin kokoonpanovaihtoehtoihin.

Täydellinen lähdekoodi löytyy myös tämän blogin arkistosta osoitteesta Mostlylucid/src/js/mermaid_enhancements.js ja avoimen lähdekoodin npm pakettina osoitteessa Lähinnä lusid-verkkoa/lähinlylusid-merineitoa.

Esimerkkikaavio

Tässä monimutkainen esimerkki tämän blogin sisältöjärjestelmän arkkitehtuurista:

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

Klikkaa ohjaimia yllä olevasta kaaviosta!

logo

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