Widget de visualisation graphique de colonnes

Bonjour,

Pour ceux qui souhaiteraient un widget souple pour visualiser graphiquement des données de colonnes différentes (texte, choix unique, choix multiple, numérique), par exemple dans le cadre d’une exploitation d’un résultat de sondage.

Ce code ne demande qu’à être optimisé mais il a le mérite d’exister !

Pour le mettre en place, cela nécessite de le lier avec la table que vous souhaitez visualiser.
Pour chaque donnée vous pouvez choisir le type de graphique le plus adapté dans l’onglet « type ».
Vous pouvez également choisir de masquer certaines colonnes que vous ne souhaitez pas voir.
Vous pouvez changer les couleurs des données visualisées (version bêta)
:warning: Il fait appel à un certain nombre de bibliothèques externes listées en en-tête de script

Pour des questions de limitations du forum le code est publié en 2 parties:

Widget:

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Visualisation Graphique</title>
    <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
    <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
    <link href="https://fonts.googleapis.com/css2?family=Marianne:wght@400;500;700&display=swap" rel="stylesheet">
    <style>
        :root {
            --primary: #2563eb;
            --primary-hover: #1d4ed8;
            --primary-dark: #1e40af;
            --success: #10b981;
            --danger: #ef4444;
            --warning: #f59e0b;
            --background: #ffffff;
            --card-bg: #ffffff;
            --text: #1e293b;
            --text-muted: #64748b;
            --border: #e2e8f0;
            --grey-50: #f8fafc;
            --grey-100: #f1f5f9;
            --grey-200: #e2e8f0;
            --grey-300: #cbd5e1;
            --grey-400: #94a3b8;
            --grey-500: #64748b;
            --grey-600: #475569;
            --grey-700: #334155;
            --grey-800: #1e293b;
            --grey-900: #0f172a;
            --radius: 6px;
            --shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
            --toggle-off: #e2e8f0;
            --toggle-on: #2563eb;
        }

        [data-theme="dark"] {
            --background: #0f172a;
            --card-bg: #1e293b;
            --text: #f8fafc;
            --text-muted: #94a3b8;
            --border: #334155;
            --grey-50: #1e293b;
            --grey-100: #334155;
            --grey-200: #475569;
            --primary: #3b82f6;
            --primary-hover: #2563eb;
            --toggle-off: #475569;
        }

        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            transition: background 0.2s, color 0.2s, border-color 0.2s;
        }

        body {
            font-family: "Marianne", system-ui, -apple-system, sans-serif;
            background: var(--background);
            color: var(--text);
            padding: 1rem;
            line-height: 1.5;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
            background: var(--card-bg);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            box-shadow: var(--shadow);
        }

        .header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 1rem 1.5rem;
            border-bottom: 1px solid var(--border);
        }

        .title {
            font-size: 1.25rem;
            font-weight: 700;
            color: var(--primary);
            margin: 0;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            max-width: 60%;
            flex: 0 1 auto;
        }

        .header-actions {
            display: flex;
            gap: 0.5rem;
            align-items: center;
        }

        .btn {
            background: var(--grey-50);
            border: 1px solid var(--border);
            padding: 0.375rem 0.75rem;
            cursor: pointer;
            font-size: 0.875rem;
            font-weight: 500;
            border-radius: var(--radius);
            transition: all 0.2s;
            color: var(--text);
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 0.25rem;
        }

        .btn:hover {
            background: var(--grey-200);
        }

        .btn-primary {
            background: var(--primary);
            color: white;
            border-color: var(--primary);
        }

        .btn-primary:hover {
            background: var(--primary-hover);
        }

        .btn:disabled {
            opacity: 0.6;
            cursor: not-allowed;
        }

        .info-text {
            font-size: 0.875rem;
            color: var(--text-muted);
            margin-left: 0.75rem;
        }

        .navigation {
            display: flex;
            padding: 1rem 1.5rem;
            gap: 1rem;
            border-bottom: 1px solid var(--border);
            align-items: center;
            flex-wrap: wrap;
        }

        .nav-container {
            display: flex;
            align-items: center;
            gap: 1rem;
            flex: 1;
            min-width: 0;
            flex-wrap: wrap;
        }

        .nav-controls {
            display: flex;
            gap: 0.5rem;
            flex-shrink: 0;
        }

        .column-title {
            font-size: 1.125rem;
            font-weight: 600;
            color: var(--primary);
            flex: 1;
            min-width: 0;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }

        .controls-bar {
            display: flex;
            gap: 1rem;
            padding: 0.75rem 1.5rem;
            border-bottom: 1px solid var(--border);
            align-items: center;
            flex-wrap: wrap;
        }

        .control-group {
            display: flex;
            align-items: center;
            gap: 0.75rem;
        }

   .select {
            background: var(--card-bg);
            border: 1px solid var(--border);
            padding: 0.375rem 0.75rem;
            font-size: 0.875rem;
            color: var(--text);
            cursor: pointer;
            border-radius: var(--radius);
            min-width: 120px;
            appearance: none;
            background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2364748b'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e");
            background-repeat: no-repeat;
            background-position: right 0.5rem center;
            background-size: 1em;
            padding-right: 2rem;
        }

        .toggle-wrapper {
            display: flex;
            align-items: center;
            gap: 0.5rem;
            cursor: pointer;
            margin-right: 1rem;
        }

        .toggle {
            position: relative;
            width: 40px;
            height: 20px;
            background: var(--toggle-off);
            border-radius: 20px;
            cursor: pointer;
            appearance: none;
            -webkit-appearance: none;
        }

        .toggle::after {
            content: '';
            position: absolute;
            top: 2px;
            left: 2px;
            width: 16px;
            height: 16px;
            background: white;
            border-radius: 50%;
            transition: transform 0.2s;
        }

        .toggle:checked {
            background: var(--toggle-on);
        }

        .toggle:checked::after {
            transform: translateX(20px);
        }

        .toggle-label {
            font-size: 0.875rem;
            font-weight: 500;
            cursor: pointer;
        }

        .content {
            padding: 1.5rem;
            min-height: 500px;
        }

        .chart-container {
            position: relative;
            width: 100%;
            min-height: 450px;
        }

        canvas {
            max-height: 500px !important;
        }

        .multiple-choice-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
            gap: 1rem;
        }

        .choice-card {
            background: var(--card-bg);
            border: 1px solid var(--border);
            padding: 1rem;
            border-radius: var(--radius);
            transition: border-color 0.2s, transform 0.1s;
        }

        .choice-card:hover {
            border-color: var(--primary);
            transform: translateY(-2px);
        }

        .choice-label {
            font-size: 0.9rem;
            font-weight: 500;
            margin-bottom: 0.5rem;
            color: var(--text);
        }

        .choice-bar-container {
            background: var(--grey-100);
            height: 1.5rem;
            position: relative;
            border-radius: 1rem;
            overflow: hidden;
        }

        .choice-bar {
            height: 100%;
            background: var(--primary);
            transition: width 0.6s ease;
            display: flex;
            align-items: center;
            justify-content: flex-end;
            padding: 0 0.75rem;
            border-radius: 1rem;
        }

        .choice-count {
            color: white;
            font-weight: 600;
            font-size: 0.8rem;
        }

        .choice-percentage {
            position: absolute;
            right: 0.75rem;
            top: 50%;
            transform: translateY(-50%);
            font-size: 0.8rem;
            font-weight: 600;
            color: white;
        }

        .wordcloud {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            align-items: center;
            gap: 0.75rem;
            padding: 1.5rem;
            min-height: 400px;
        }

        .word {
            display: inline-block;
            padding: 0.5rem 1rem;
            background: var(--primary);
            color: white;
            font-weight: 500;
            border-radius: 1rem;
            transition: transform 0.2s;
        }

        .word:hover {
            transform: scale(1.05);
        }

        .stats {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
            gap: 1rem;
            margin-top: 1.5rem;
            padding-top: 1rem;
            border-top: 1px solid var(--border);
        }

        .stat-card {
            background: var(--primary);
            color: white;
            padding: 1.25rem;
            text-align: center;
            border-radius: var(--radius);
        }

        .stat-label {
            font-size: 0.8rem;
            opacity: 0.9;
            margin-bottom: 0.5rem;
        }

        .stat-value {
            font-size: 1.75rem;
            font-weight: 700;
        }

        .loading, .error {
            text-align: center;
            padding: 2rem;
            font-size: 1rem;
            border-radius: var(--radius);
        }

        .error {
            background: #fee2e2;
            color: var(--danger);
            border: 1px solid var(--danger);
        }

        .boolean-container {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 1.5rem;
            padding: 1.5rem;
        }

        .boolean-item {
            text-align: center;
            padding: 1.5rem;
            background: var(--card-bg);
            border: 2px solid var(--border);
            border-radius: var(--radius);
            transition: border-color 0.2s;
        }

        .boolean-item:hover {
            border-color: var(--primary);
        }

        .boolean-item.true {
            border-color: var(--success);
        }

        .boolean-item.false {
            border-color: var(--danger);
        }

        .boolean-icon {
            font-size: 2.5rem;
            margin-bottom: 0.75rem;
        }

        .boolean-label {
            font-size: 1.1rem;
            font-weight: 600;
            margin-bottom: 0.5rem;
        }

        .boolean-count {
            font-size: 2rem;
            font-weight: 700;
            color: var(--text);
        }

        .modal {
            display: none;
            position: fixed;
            z-index: 1000;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.5);
        }

        .modal-content {
            background: var(--card-bg);
            margin: 5% auto;
            padding: 1.5rem;
            border: 1px solid var(--border);
            width: 90%;
            max-width: 500px;
            border-radius: var(--radius);
            box-shadow: var(--shadow);
        }

        .modal-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 1rem;
            padding-bottom: 0.75rem;
            border-bottom: 1px solid var(--border);
        }

        .modal-title {
            font-size: 1.125rem;
            font-weight: 600;
            color: var(--primary);
        }

        .close-modal {
            font-size: 1.5rem;
            cursor: pointer;
            color: var(--text-muted);
        }

        .close-modal:hover {
            color: var(--text);
        }

        .column-list {
            display: flex;
            flex-direction: column;
            gap: 0.5rem;
            margin-bottom: 1rem;
            max-height: 400px;
            overflow-y: auto;
        }

        .column-item {
            display: flex;
            align-items: center;
            gap: 0.75rem;
            padding: 0.5rem 0.75rem;
            background: var(--grey-50);
            border: 1px solid var(--border);
            border-radius: var(--radius);
        }

        .column-label {
            flex: 1;
            font-size: 0.9rem;
            font-weight: 500;
        }

        .badge {
            padding: 0.25rem 0.5rem;
            font-size: 0.7rem;
            background: var(--grey-200);
            color: var(--text-muted);
            border-radius: 1rem;
        }

        .color-item {
            display: flex;
            align-items: center;
            gap: 0.75rem;
            padding: 0.5rem 0.75rem;
            background: var(--grey-50);
            border: 1px solid var(--border);
            border-radius: var(--radius);
        }

Partie 2/2


        .color-picker {
            width: 1.75rem;
            height: 1.75rem;
            border: none;
            cursor: pointer;
            border-radius: 4px;
        }

        .export-modal {
            display: none;
            position: fixed;
            z-index: 1001;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.5);
        }

        .export-modal-content {
            background: var(--card-bg);
            margin: 15% auto;
            padding: 1.5rem;
            border: 1px solid var(--border);
            width: 90%;
            max-width: 350px;
            text-align: center;
            border-radius: var(--radius);
            box-shadow: var(--shadow);
        }

        .export-options {
            display: flex;
            flex-direction: column;
            gap: 0.75rem;
            margin: 1rem 0;
        }

        @media (max-width: 768px) {
            body {
                padding: 0.5rem;
            }
            .container {
                border-radius: 0;
                border-left: none;
                border-right: none;
            }
            .header, .navigation, .controls-bar, .content {
                padding: 0.75rem;
            }
            .nav-controls {
                flex-basis: 100%;
                justify-content: center;
                margin-top: 0.75rem;
            }
            .controls-bar {
                flex-direction: column;
                gap: 0.75rem;
                align-items: stretch;
            }
            .control-group {
                width: 100%;
                justify-content: space-between;
            }
            .multiple-choice-grid, .boolean-container {
                grid-template-columns: 1fr;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1 class="title">📊 Visualisation Graphique</h1>
            <div class="header-actions">
                <button class="btn" id="themeToggle" title="Changer de thème">🌙</button>
                <button class="btn" id="configBtn">⚙️ Colonnes</button>
                <button class="btn" id="exportBtn">📥 Exporter</button>
                <span class="info-text" id="recordCount"></span>
            </div>
        </div>
        <div class="navigation">
            <div class="nav-container">
                <button class="btn btn-primary" id="prevBtn" onclick="navigateColumn(-1)">◀ Précédent</button>
                <div class="column-title" id="columnName">Chargement...</div>
                <button class="btn btn-primary" id="nextBtn" onclick="navigateColumn(1)">Suivant ▶</button>
            </div>
        </div>
        <div class="controls-bar">
            <div class="control-group">
                <label for="chartType">Type :</label>
                <select class="select" id="chartType" onchange="updateVisualization()">
                    <option value="auto">Auto</option>
                    <option value="bar">Barres</option>
                    <option value="line">Courbe</option>
                    <option value="doughnut">Donut</option>
                    <option value="pie">Camembert</option>
                    <option value="cards">Cartes</option>
                    <option value="wordcloud">Nuage de mots</option>
                </select>
            </div>
            <div class="control-group">
                <label class="toggle-wrapper">
                    <input type="checkbox" class="toggle" id="showPercentages" onchange="updateVisualization()" checked>
                    <span class="toggle-label">Pourcentages</span>
                </label>
                <label class="toggle-wrapper">
                    <input type="checkbox" class="toggle" id="showDataLabels" onchange="updateVisualization()">
                    <span class="toggle-label">Étiquettes</span>
                </label>
                <button class="btn" id="colorConfigBtn">🎨 Couleurs</button>
            </div>
        </div>
        <div class="content" id="content">
            <div class="loading">Connexion à Grist...</div>
        </div>
    </div>
    <!-- Modal de configuration des colonnes -->
    <div id="configModal" class="modal">
        <div class="modal-content">
            <div class="modal-header">
                <h2 class="modal-title">Sélection des colonnes</h2>
                <span class="close-modal" onclick="closeConfigModal()">&times;</span>
            </div>
            <div style="margin-bottom: 1rem;">
                <p style="margin-bottom: 0.75rem; font-size: 0.9rem; color: var(--text-muted);">
                    Sélectionnez les colonnes à afficher :
                </p>
                <div class="column-list" id="columnList"></div>
            </div>
            <button class="btn btn-primary" style="width: 100%;" onclick="saveColumnConfig()">Enregistrer</button>
        </div>
    </div>
    <!-- Modal de configuration des couleurs -->
    <div id="colorModal" class="modal">
        <div class="modal-content">
            <div class="modal-header">
                <h2 class="modal-title">Personnaliser les couleurs</h2>
                <span class="close-modal" onclick="closeColorModal()">&times;</span>
            </div>
            <div id="colorList"></div>
            <button class="btn btn-primary" style="width: 100%; margin-top: 1rem;" onclick="saveColorConfig()">Enregistrer</button>
        </div>
    </div>
    <!-- Modal d'export -->
    <div id="exportModal" class="export-modal">
        <div class="export-modal-content">
            <h2>Exporter en PDF</h2>
            <div class="export-options">
                <button class="btn btn-primary" onclick="exportCurrentPage()">Page actuelle</button>
                <button class="btn btn-primary" onclick="exportAllPages()">Toutes les pages</button>
            </div>
            <button class="btn" onclick="closeExportModal()" style="margin-top: 0.75rem;">Annuler</button>
        </div>
    </div>
    <script>
        // Le JavaScript reste strictement inchangé
        // Variables globales
        let tableData = [];
        let allColumns = [];
        let selectedColumns = [];
        let columnsMeta = {};
        let currentColumnIndex = 0;
        let chart = null;
        let customColors = {};
        let columnVisualizationTypes = {};
        const defaultColors = [
            '#000091', '#e1000f', '#00a95f', '#009081', '#6a6af4',
            '#ff5655', '#34cb6a', '#21ab8e', '#8585f5', '#ff7f7f',
            '#68d89b', '#4cc0b0', '#a3a3f7', '#ffa3a3', '#8ee4bb',
            '#ffd700', '#ff8c00', '#a0522d', '#9370db', '#00ced1'
        ];
        // Gestion du thème
        const themeToggle = document.getElementById('themeToggle');
        const savedTheme = localStorage.getItem('grist-widget-theme') || 'light';
        document.documentElement.setAttribute('data-theme', savedTheme);
        themeToggle.addEventListener('click', () => {
            const current = document.documentElement.getAttribute('data-theme');
            const newTheme = current === 'dark' ? 'light' : 'dark';
            document.documentElement.setAttribute('data-theme', newTheme);
            localStorage.setItem('grist-widget-theme', newTheme);
            if (chart) updateVisualization();
        });
        // Initialisation Grist
        grist.ready({
            requiredAccess: 'read table',
            columns: [],
            allowSelectBy: true
        });
        // Fonction pour récupérer le type précis d'une colonne via fetchTables
        async function fetchColumnTypes() {
            try {
                const tables = await grist.docApi.fetchTables();
                const tableName = Object.keys(tables)[0];
                if (!tableName) return {};
                const table = tables[tableName];
                const columnTypes = {};
                table.columns.forEach(col => {
                    columnTypes[col.id] = {
                        type: col.type,
                        choices: col.widgetOptions?.choices || null
                    };
                });
                return columnTypes;
            } catch (error) {
                console.error("Erreur lors de la récupération des types de colonnes :", error);
                return {};
            }
        }
        // Réception des données
        grist.onRecords(async function(records, mappings) {
            console.log('📊 Records:', records?.length);
            console.log('📋 Mappings:', mappings);
            const columnTypes = await fetchColumnTypes();
            console.log('🔍 Types de colonnes:', columnTypes);
            if (!records || records.length === 0) {
                document.getElementById('content').innerHTML = '<div class="error">Aucune donnée disponible</div>';
                return;
            }
            tableData = records;
            if (mappings) {
                allColumns = Object.keys(mappings);
                allColumns.forEach(colId => {
                    const m = mappings[colId];
                    const preciseType = columnTypes[colId]?.type || m.type || 'Text';
                    const choices = columnTypes[colId]?.choices || null;
                    columnsMeta[colId] = {
                        id: colId,
                        type: preciseType,
                        choices: choices,
                        label: m.label || colId,
                        description: m.description || m.label || colId,
                        displayName: m.description || m.label || colId
                    };
                });
            } else {
                allColumns = Object.keys(records[0]).filter(k => k !== 'id' && !k.startsWith('_'));
                allColumns.forEach(colId => {
                    const preciseType = columnTypes[colId]?.type || 'Text';
                    const choices = columnTypes[colId]?.choices || null;
                    columnsMeta[colId] = {
                        id: colId,
                        type: preciseType,
                        choices: choices,
                        label: colId,
                        description: colId,
                        displayName: colId
                    };
                });
            }
            console.log('✅ Colonnes:', allColumns);
            console.log('✅ Métadonnées précises:', columnsMeta);
            selectedColumns = [...allColumns];
            updateColumnList();
            updateVisualization();
        });
        function updateColumnList() {
            const list = document.getElementById('columnList');
            list.innerHTML = '';
            allColumns.forEach(colId => {
                const meta = columnsMeta[colId];
                const isChecked = selectedColumns.includes(colId);
                const item = document.createElement('div');
                item.className = 'column-item';
                const checkbox = document.createElement('input');
                checkbox.type = 'checkbox';
                checkbox.className = 'checkbox';
                checkbox.checked = isChecked;
                checkbox.onchange = () => toggleColumn(colId);
                const label = document.createElement('span');
                label.className = 'column-label';
                label.textContent = meta.displayName;
                const badge = document.createElement('span');
                badge.className = 'badge';
                badge.textContent = meta.type;
                item.appendChild(checkbox);
                item.appendChild(label);
                item.appendChild(badge);
                list.appendChild(item);
            });
        }
        function toggleColumn(colId) {
            const idx = selectedColumns.indexOf(colId);
            if (idx === -1) {
                selectedColumns.push(colId);
            } else {
                selectedColumns.splice(idx, 1);
            }
        }
        document.getElementById('configBtn').onclick = () => {
            document.getElementById('configModal').style.display = 'block';
        };
        function closeConfigModal() {
            document.getElementById('configModal').style.display = 'none';
        }
        function saveColumnConfig() {
            if (selectedColumns.length === 0) {
                alert("Veuillez sélectionner au moins une colonne.");
                return;
            }
            closeConfigModal();
            if (currentColumnIndex >= selectedColumns.length) {
                currentColumnIndex = Math.max(0, selectedColumns.length - 1);
            }
            updateVisualization();
        }
        document.getElementById('exportBtn').onclick = () => {
            document.getElementById('exportModal').style.display = 'block';
        };
        function closeExportModal() {
            document.getElementById('exportModal').style.display = 'none';
        }
        window.onclick = (e) => {
            const configModal = document.getElementById('configModal');
            const colorModal = document.getElementById('colorModal');
            const exportModal = document.getElementById('exportModal');
            if (e.target === configModal) closeConfigModal();
            if (e.target === colorModal) closeColorModal();
            if (e.target === exportModal) closeExportModal();
        };
        function navigateColumn(dir) {
            if (selectedColumns.length === 0) return;
            currentColumnIndex = Math.max(0, Math.min(selectedColumns.length - 1, currentColumnIndex + dir));
            const colId = selectedColumns[currentColumnIndex];
            const savedType = columnVisualizationTypes[colId] || 'auto';
            document.getElementById('chartType').value = savedType;
            updateVisualization();
        }
        function updateVisualization() {
            if (!tableData || selectedColumns.length === 0) {
                document.getElementById('content').innerHTML = '<div class="error">Aucune colonne sélectionnée</div>';
                return;
            }
            const colId = selectedColumns[currentColumnIndex];
            const meta = columnsMeta[colId];
            const columnData = tableData
                .map(row => row[colId])
                .filter(val => val !== null && val !== undefined && val !== '');
            console.log(`🎨 Colonne: ${colId}, Type: ${meta.type}, Valeurs: ${columnData.length}`);
            document.getElementById('columnName').textContent = meta.displayName;
            document.getElementById('recordCount').textContent =
                `${tableData.length} réponses • ${currentColumnIndex + 1}/${selectedColumns.length}`;
            document.getElementById('prevBtn').disabled = currentColumnIndex === 0;
            document.getElementById('nextBtn').disabled = currentColumnIndex >= selectedColumns.length - 1;
            const chartTypeSelect = document.getElementById('chartType').value;
            columnVisualizationTypes[colId] = chartTypeSelect;
            const autoType = detectColumnType(meta.type, columnData, meta.choices);
            const finalType = chartTypeSelect === 'auto' ? autoType : chartTypeSelect;
            console.log(`Type auto: ${autoType}, Type final: ${finalType}`);
            renderVisualization(columnData, finalType);
        }
        function detectColumnType(gristType, data, choices) {
            switch (gristType) {
                case 'Bool':
                    return 'pie';
                case 'Int':
                case 'Numeric':
                    return 'line';
                case 'Choice':
                    return 'doughnut';
                case 'ChoiceList':
                    return 'bar';
                case 'Ref':
                    return 'doughnut';
                case 'Text':
                default:
                    if (data.length === 0) return 'wordcloud';
                    if (choices && choices.length > 0 && choices.length <= 20) {
                        return 'cards';
                    }
                    const uniqueValues = [...new Set(data)];
                    if (uniqueValues.length <= 20) return 'cards';
                    return 'wordcloud';
            }
        }
        function renderVisualization(data, type) {
            const content = document.getElementById('content');
            if (chart) {
                chart.destroy();
                chart = null;
            }
            if (type === 'boolean') {
                renderBoolean(content, data);
            } else if (type === 'cards') {
                renderCards(content, data);
            } else if (type === 'wordcloud') {
                renderWordCloud(content, data);
            } else {
                renderChart(content, data, type);
            }
        }
        function renderBoolean(container, data) {
            const trueVals = ['true', '1', 'oui', 'yes', 'vrai'];
            const trueCount = data.filter(v => {
                if (typeof v === 'boolean') return v;
                if (typeof v === 'number') return v === 1;
                return trueVals.includes(String(v).toLowerCase());
            }).length;
            const falseCount = data.length - trueCount;
            const total = data.length;
            container.innerHTML = `
                <div class="boolean-container">
                    <div class="boolean-item true">
                        <div class="boolean-icon">✓</div>
                        <div class="boolean-label">Oui / Vrai</div>
                        <div class="boolean-count">${trueCount}</div>
                        <div class="stat-label">${total > 0 ? Math.round((trueCount/total)*100) : 0}%</div>
                    </div>
                    <div class="boolean-item false">
                        <div class="boolean-icon">✗</div>
                        <div class="boolean-label">Non / Faux</div>
                        <div class="boolean-count">${falseCount}</div>
                        <div class="stat-label">${total > 0 ? Math.round((falseCount/total)*100) : 0}%</div>
                    </div>
                </div>
            `;
        }
        function renderCards(container, data) {
            const counts = {};
            data.forEach(val => {
                if (Array.isArray(val)) {
                    val.forEach(v => {
                        if (v) counts[v] = (counts[v] || 0) + 1;
                    });
                } else if (typeof val === 'string') {
                    if (val.startsWith('[')) {
                        try {
                            JSON.parse(val.replace(/'/g, '"')).forEach(v => {
                                if (v) counts[v] = (counts[v] || 0) + 1;
                            });
                        } catch {
                            val.replace(/[\[\]'"]/g, '').split(/[,;]/).map(c => c.trim()).forEach(v => {
                                if (v) counts[v] = (counts[v] || 0) + 1;
                            });
                        }
                    } else {
                        counts[val] = (counts[val] || 0) + 1;
                    }
                } else {
                    counts[String(val)] = (counts[String(val)] || 0) + 1;
                }
            });
            const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
            const total = data.length;
            const maxCount = sorted[0]?.[1] || 1;
            const showPct = document.getElementById('showPercentages').checked;
            const html = sorted.map(([label, count]) => {
                const pct = ((count / total) * 100).toFixed(1);
                const barWidth = (count / maxCount) * 100;
                const countDisplay = showPct ? '' : `<span class="choice-count">${count}</span>`;
                const percentageDisplay = showPct ? `<span class="choice-percentage">${pct}%</span>` : '';
                return `
                    <div class="choice-card">
                        <div class="choice-label">${label}</div>
                        <div class="choice-bar-container">
                            <div class="choice-bar" style="width: ${barWidth}%">
                                ${countDisplay}
                            </div>
                            ${percentageDisplay}
                        </div>
                    </div>
                `;
            }).join('');
            container.innerHTML = `<div class="multiple-choice-grid">${html}</div>`;
        }
        function renderWordCloud(container, data) {
            const words = {};
            data.forEach(text => {
                String(text).toLowerCase()
                    .replace(/[^\w\sàâäéèêëïîôùûüÿæœç]/g, ' ')
                    .split(/\s+/)
                    .filter(w => w.length > 3)
                    .forEach(w => words[w] = (words[w] || 0) + 1);
            });
            const sorted = Object.entries(words).sort((a, b) => b[1] - a[1]).slice(0, 50);
            const maxCount = sorted[0]?.[1] || 1;
            const html = sorted.map(([word, count]) => {
                const size = 0.75 + (count / maxCount) * 1.5;
                return `<span class="word" style="font-size: ${size}rem">${word} (${count})</span>`;
            }).join('');
            container.innerHTML = `<div class="wordcloud">${html || '<div class="loading">Aucun mot</div>'}</div>`;
        }
        function renderChart(container, data, type) {
            const counts = {};
            data.forEach(v => {
                const key = Array.isArray(v) ? v.join(', ') : String(v);
                counts[key] = (counts[key] || 0) + 1;
            });
            const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
            const labels = sorted.map(([l]) => l);
            const values = sorted.map(([, c]) => c);
            const total = values.reduce((a, b) => a + b, 0);
            const showPct = document.getElementById('showPercentages').checked;
            const showDataLabels = document.getElementById('showDataLabels').checked;
            container.innerHTML = '<div class="chart-container"><canvas id="chart"></canvas></div>';
            const ctx = document.getElementById('chart').getContext('2d');
            const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
            const textColor = isDark ? '#f6f6f6' : '#161616';
            const gridColor = isDark ? '#3a3a3a' : '#e5e5e5';
            const labelColor = isDark ? '#161616' : '#ffffff';
            const labelBorderColor = isDark ? '#f6f6f6' : '#161616';
            const backgroundColors = labels.map((label, index) => {
                if (!customColors[label]) {
                    customColors[label] = defaultColors[index % defaultColors.length];
                }
                return customColors[label];
            });
            const dataLabelsPlugin = {
                id: 'dataLabels',
                afterDatasetDraw(chart) {
                    if (!showDataLabels) return;
                    const ctx = chart.ctx;
                    ctx.font = 'bold 10px Marianne';
                    ctx.textAlign = 'center';
                    ctx.textBaseline = 'middle';
                    const meta = chart.getDatasetMeta(0);
                    meta.data.forEach((element, index) => {
                        const value = chart.data.datasets[0].data[index];
                        const label = chart.data.labels[index];
                        const percent = ((value / total) * 100).toFixed(1);
                        const text = showPct ? `${label} - ${percent}%` : `${label} - ${value}`;
                        const position = element.tooltipPosition();
                        ctx.strokeStyle = labelBorderColor;
                        ctx.lineWidth = 2;
                        ctx.strokeText(text, position.x, position.y);
                        ctx.fillStyle = labelColor;
                        ctx.fillText(text, position.x, position.y);
                    });
                }
            };
            let chartOptions = {
                responsive: true,
                maintainAspectRatio: false,
                plugins: {
                    legend: {
                        display: !(showDataLabels || showPct),
                        position: 'right',
                        labels: {
                            color: textColor,
                            font: { size: 12, family: 'Marianne' }
                        }
                    },
                    tooltip: {
                        callbacks: {
                            label: function(ctx) {
                                const value = ctx.parsed;
                                const percent = ((value / total) * 100).toFixed(1);
                                return showPct
                                    ? `${ctx.label}: ${percent}%`
                                    : `${ctx.label}: ${value}`;
                            }
                        }
                    }
                },
                scales: {
                    y: { ticks: { color: textColor }, grid: { color: gridColor } },
                    x: { ticks: { color: textColor }, grid: { color: gridColor } }
                }
            };
            if (type === 'line') {
                chartOptions.scales.y.beginAtZero = true;
                chartOptions.elements = {
                    line: { tension: 0.4, borderWidth: 2 },
                    point: { radius: 4, hoverRadius: 6 }
                };
            }
            chart = new Chart(ctx, {
                type: type,
                data: {
                    labels: labels,
                    datasets: [{
                        label: 'Réponses',
                        data: values,
                        backgroundColor: type === 'line' ? 'transparent' : backgroundColors,
                        borderColor: type === 'line' ? defaultColors[0] : '#fff',
                        borderWidth: type === 'line' ? 2 : 2,
                        fill: type === 'line' ? false : true,
                        tension: type === 'line' ? 0.4 : 0
                    }]
                },
                options: chartOptions,
                plugins: [dataLabelsPlugin]
            });
        }
        function getDefaultColor(label) {
            const index = Object.keys(customColors).length % defaultColors.length;
            return defaultColors[index];
        }
        document.getElementById('colorConfigBtn').onclick = () => {
            const colId = selectedColumns[currentColumnIndex];
            const columnData = tableData.map(row => row[colId]).filter(val => val !== null && val !== undefined && val !== '');
            const counts = {};
            columnData.forEach(v => {
                const key = Array.isArray(v) ? v.join(', ') : String(v);
                counts[key] = (counts[key] || 0) + 1;
            });
            const labels = Object.keys(counts);
            renderColorList(labels);
            document.getElementById('colorModal').style.display = 'block';
        };
        function renderColorList(labels) {
            const list = document.getElementById('colorList');
            list.innerHTML = '';
            labels.forEach(label => {
                const item = document.createElement('div');
                item.className = 'color-item';
                const colorInput = document.createElement('input');
                colorInput.type = 'color';
                colorInput.className = 'color-picker';
                colorInput.value = customColors[label] || getDefaultColor(label);
                colorInput.onchange = () => {
                    customColors[label] = colorInput.value;
                };
                const labelSpan = document.createElement('span');
                labelSpan.textContent = label;
                item.appendChild(colorInput);
                item.appendChild(labelSpan);
                list.appendChild(item);
            });
        }
        function closeColorModal() {
            document.getElementById('colorModal').style.display = 'none';
        }
        function saveColorConfig() {
            closeColorModal();
            updateVisualization();
        }
        function closeExportModal() {
            document.getElementById('exportModal').style.display = 'none';
        }
        async function exportCurrentPage() {
            const content = document.querySelector('.content');
            const canvas = await html2canvas(content, { scale: 2 });
            const pdf = new jspdf.jsPDF('p', 'mm', 'a4');
            const imgData = canvas.toDataURL('image/png');
            const imgWidth = pdf.internal.pageSize.getWidth();
            const imgHeight = (canvas.height * imgWidth) / canvas.width;
            pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
            pdf.save(`visualisation-${document.getElementById('columnName').textContent}.pdf`);
            closeExportModal();
        }
        async function exportAllPages() {
            const pdf = new jspdf.jsPDF('p', 'mm', 'a4');
            const originalIndex = currentColumnIndex;
            for (let i = 0; i < selectedColumns.length; i++) {
                currentColumnIndex = i;
                updateVisualization();
                await new Promise(resolve => setTimeout(resolve, 500));
                const content = document.querySelector('.content');
                const canvas = await html2canvas(content, { scale: 2 });
                const imgData = canvas.toDataURL('image/png');
                const imgWidth = pdf.internal.pageSize.getWidth();
                const imgHeight = (canvas.height * imgWidth) / canvas.width;
                if (i > 0) pdf.addPage();
                pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
                pdf.setFontSize(12);
                pdf.text(`Colonne: ${columnsMeta[selectedColumns[i]].displayName}`, 10, 10);
            }
            currentColumnIndex = originalIndex;
            updateVisualization();
            pdf.save('visualisation-complete.pdf');
            closeExportModal();
        }
    </script>
</body>
</html>
1 « J'aime »