{"id":23813672,"date":"2026-05-20T17:24:49","date_gmt":"2026-05-20T17:24:49","guid":{"rendered":"https:\/\/socialhackerslab.com\/?page_id=23813672"},"modified":"2026-06-04T15:14:48","modified_gmt":"2026-06-04T15:14:48","slug":"cartographie-des-projets-et-financements","status":"publish","type":"page","link":"https:\/\/socialhackerslab.com\/en\/cartographie-des-projets-et-financements\/","title":{"rendered":"cartographie-des-projets-et-financements"},"content":{"rendered":"<script src=\"https:\/\/d3js.org\/d3.v7.min.js\"><\/script>\r\n\r\n<style>\r\n@import url('https:\/\/fonts.googleapis.com\/css2?family=Manrope:wght@400;500;600;700;800;900&display=swap');\r\n\r\n\/* WPCode Lite \/ WordPress plein \u00e9cran *\/\r\nhtml:has(#shl-roue-wp), body:has(#shl-roue-wp){\r\n    margin: 0 !important;\r\n    padding: 0 !important;\r\n    overflow: hidden !important;\r\n}\r\n\/* \u00c9l\u00e9ments natifs du th\u00e8me WordPress (hors du container) \u2192 on les masque\r\n   uniquement sur les pages qui h\u00e9bergent la roue, via :has(). *\/\r\nbody:has(#shl-roue-wp) .entry-title,\r\nbody:has(#shl-roue-wp) .page-title,\r\nbody:has(#shl-roue-wp) h1.elementor-heading-title,\r\nbody:has(#shl-roue-wp) .nv-page-title,\r\nbody:has(#shl-roue-wp) .site-header,\r\nbody:has(#shl-roue-wp) .site-footer,\r\nbody:has(#shl-roue-wp) header.site-header,\r\nbody:has(#shl-roue-wp) footer.site-footer{\r\n    display: none !important;\r\n}\r\n#shl-roue-wp {\r\n    --bg-color: #fbfaf8;\r\n    --text-color: #24476d;\r\n    --muted-text: #6d7580;\r\n    --sidebar-bg: rgba(255, 251, 247, 0.92);\r\n    --border-color: #e7dfd9;\r\n    --accent-color: #6d4f84;\r\n    --font-family: 'Manrope', sans-serif;\r\n    --chip-bg: rgba(255, 255, 255, 0.84);\r\n    --chip-shadow: 0 10px 30px rgba(36, 71, 109, 0.08);\r\n\r\n    position: fixed !important;\r\n    inset: 0 !important;\r\n    width: 100vw !important;\r\n    height: 100vh !important;\r\n    z-index: 9999999 !important;\r\n    font-family: var(--font-family) !important;\r\n    background:\r\n        radial-gradient(circle at top, rgba(255, 255, 255, 0.95), rgba(251, 250, 248, 0.98) 44%, rgba(244, 240, 235, 0.98)),\r\n        linear-gradient(135deg, rgba(109, 79, 132, 0.04), rgba(46, 128, 98, 0.03)) !important;\r\n    background-color: var(--bg-color) !important;\r\n    color: var(--text-color) !important;\r\n    overflow: hidden !important;\r\n    display: flex !important;\r\n    flex-direction: column !important;\r\n}\r\n#shl-roue-wp * {\r\n    box-sizing: border-box;\r\n}\r\n#shl-roue-wp #app-container {\r\n    height: 100%;\r\n    width: 100%;\r\n}\r\n\r\n#shl-roue-wp #app-container{\r\n            display: flex;\r\n            flex-direction: column;\r\n            height: 100%;\r\n            width: 100%;\r\n            max-width: none;\r\n            margin: 0 auto;\r\n            position: relative;\r\n        }\r\n\r\n        #shl-roue-wp .header{\r\n            position: absolute;\r\n            top: 1.05rem;\r\n            left: 1.1rem;\r\n            right: auto;\r\n            width: fit-content;\r\n            max-width: calc(100vw - 2.2rem);\r\n            display: block;\r\n            padding: 0;\r\n            background: transparent;\r\n            border-bottom: none;\r\n            z-index: 10;\r\n            transition: left 0.5s cubic-bezier(0.22, 1, 0.36, 1), right 0.5s cubic-bezier(0.22, 1, 0.36, 1);\r\n        }\r\n\r\n        body.sidebar-open #shl-roue-wp .header{\r\n            left: 1.1rem;\r\n            right: auto;\r\n        }\r\n\r\n        #shl-roue-wp .map-title{\r\n            max-width: min(360px, calc(100vw - 2.2rem));\r\n            margin-bottom: 1.25rem;\r\n            text-align: left;\r\n            color: var(--text-color);\r\n            pointer-events: none;\r\n            max-height: 200px;\r\n            overflow: hidden;\r\n            transition: opacity 0.28s ease, transform 0.28s ease,\r\n                        max-height 0.42s cubic-bezier(0.22, 1, 0.36, 1),\r\n                        margin-bottom 0.42s cubic-bezier(0.22, 1, 0.36, 1);\r\n        }\r\n\r\n        #shl-roue-wp .map-title-eyebrow{\r\n            display: block;\r\n            margin-bottom: 0.18rem;\r\n            color: var(--muted-text);\r\n            font-size: 0.72rem;\r\n            font-weight: 850;\r\n            letter-spacing: 0.09em;\r\n            line-height: 1.15;\r\n            text-transform: uppercase;\r\n        }\r\n\r\n        #shl-roue-wp .map-title-main{\r\n            display: block;\r\n            color: #13293f;\r\n            font-size: clamp(1.45rem, 2.1vw, 2.15rem);\r\n            font-weight: 850;\r\n            line-height: 1.04;\r\n            letter-spacing: 0;\r\n        }\r\n\r\n        body.sidebar-open #shl-roue-wp .map-title{\r\n            opacity: 0;\r\n            transform: translateY(-4px);\r\n            max-height: 0;\r\n            margin-bottom: 0;\r\n        }\r\n\r\n        #shl-roue-wp .toolbar{\r\n            display: flex;\r\n            flex-direction: column;\r\n            align-items: flex-start;\r\n            gap: 0.55rem;\r\n            width: fit-content;\r\n            pointer-events: auto;\r\n            transform: translateX(0);\r\n            opacity: 1;\r\n            transition: opacity 0.25s ease, transform 0.25s ease;\r\n        }\r\n\r\n        #shl-roue-wp .filter-group{\r\n            display: inline-flex;\r\n            flex-direction: column;\r\n            gap: 0.35rem;\r\n            padding: 0.55rem 0.6rem 0.65rem;\r\n            border-radius: 18px;\r\n            background: var(--chip-bg);\r\n            box-shadow: var(--chip-shadow);\r\n            border: 1px solid rgba(231, 223, 217, 0.9);\r\n            min-width: 200px;\r\n            opacity: 1;\r\n            transform: translateY(0);\r\n            transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);\r\n        }\r\n\r\n        #shl-roue-wp .filter-group[hidden]{\r\n            display: none;\r\n        }\r\n\r\n        #shl-roue-wp .filter-group.entering{\r\n            opacity: 0;\r\n            transform: translateY(-6px);\r\n        }\r\n\r\n        #shl-roue-wp .filter-label{\r\n            font-size: 0.7rem;\r\n            text-transform: uppercase;\r\n            letter-spacing: 0.06em;\r\n            color: var(--muted-text);\r\n            font-weight: 700;\r\n            padding: 0.2rem 0.45rem 0.1rem;\r\n        }\r\n\r\n        #shl-roue-wp .filter-btn{\r\n            border: 1px solid rgba(36, 71, 109, 0.14);\r\n            border-radius: 12px;\r\n            padding: 0.55rem 0.7rem;\r\n            background: rgba(255, 255, 255, 0.55);\r\n            color: var(--text-color);\r\n            font: inherit;\r\n            font-size: 0.78rem;\r\n            font-weight: 600;\r\n            cursor: pointer;\r\n            text-align: left;\r\n            display: flex;\r\n            align-items: center;\r\n            gap: 0.5rem;\r\n            transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease;\r\n        }\r\n\r\n        #shl-roue-wp .filter-btn:hover{\r\n            transform: translateY(-1px);\r\n            background: rgba(255, 255, 255, 0.92);\r\n        }\r\n\r\n        #shl-roue-wp .filter-dot{\r\n            width: 10px;\r\n            height: 10px;\r\n            border-radius: 999px;\r\n            background: transparent;\r\n            border: 1.5px solid var(--filter-color, currentColor);\r\n            transition: background-color 0.2s ease;\r\n            flex: none;\r\n        }\r\n\r\n        #shl-roue-wp .filter-btn.active{\r\n            background: rgba(255, 255, 255, 0.96);\r\n            border-color: var(--filter-color, currentColor);\r\n            color: var(--filter-color, var(--text-color));\r\n        }\r\n\r\n        #shl-roue-wp .filter-btn.active .filter-dot{\r\n            background: var(--filter-color, currentColor);\r\n        }\r\n\r\n        #shl-roue-wp .toggle-group{\r\n            display: inline-flex;\r\n            flex-direction: row;\r\n            flex-wrap: nowrap;\r\n            gap: 0.5rem;\r\n            padding: 0.4rem;\r\n            border-radius: 22px;\r\n            background: var(--chip-bg);\r\n            box-shadow: var(--chip-shadow);\r\n            border: 1px solid rgba(231, 223, 217, 0.9);\r\n        }\r\n\r\n        #shl-roue-wp .zone-select-group{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 0.55rem;\r\n            padding: 0.45rem 0.55rem 0.45rem 0.75rem;\r\n            border-radius: 18px;\r\n            background: var(--chip-bg);\r\n            box-shadow: var(--chip-shadow);\r\n            border: 1px solid rgba(231, 223, 217, 0.9);\r\n            min-width: 220px;\r\n            width: fit-content;\r\n            max-width: calc(100vw - 2rem);\r\n        }\r\n\r\n        #shl-roue-wp .zone-select-group[hidden]{\r\n            display: none;\r\n        }\r\n\r\n        #shl-roue-wp .zone-select-label{\r\n            font-size: 0.72rem;\r\n            text-transform: uppercase;\r\n            letter-spacing: 0.06em;\r\n            color: var(--muted-text);\r\n            font-weight: 800;\r\n            white-space: nowrap;\r\n        }\r\n\r\n        #shl-roue-wp .zone-select{\r\n            width: auto;\r\n            min-width: 130px;\r\n            max-width: 300px;\r\n            border: 1px solid rgba(36, 71, 109, 0.16);\r\n            border-radius: 999px;\r\n            padding: 0.55rem 2rem 0.55rem 0.8rem;\r\n            background: rgba(255, 255, 255, 0.86);\r\n            color: var(--text-color);\r\n            font: inherit;\r\n            font-size: 0.82rem;\r\n            font-weight: 750;\r\n            cursor: pointer;\r\n            outline: none;\r\n            transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;\r\n        }\r\n\r\n        #shl-roue-wp .zone-select:hover, #shl-roue-wp .zone-select:focus{\r\n            background: white;\r\n            border-color: rgba(36, 71, 109, 0.34);\r\n            box-shadow: 0 8px 18px rgba(36, 71, 109, 0.1);\r\n        }\r\n\r\n        #shl-roue-wp .toggle-btn{\r\n            border: none;\r\n            border-radius: 999px;\r\n            padding: 0.78rem 1.05rem;\r\n            background: transparent;\r\n            color: var(--text-color);\r\n            font: inherit;\r\n            font-size: 0.9rem;\r\n            font-weight: 700;\r\n            cursor: pointer;\r\n            transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;\r\n        }\r\n\r\n        #shl-roue-wp .toggle-btn:hover{\r\n            transform: translateY(-1px);\r\n            background: rgba(255, 255, 255, 0.68);\r\n        }\r\n\r\n        #shl-roue-wp .toggle-btn.active{\r\n            color: white;\r\n            box-shadow: 0 12px 24px rgba(36, 71, 109, 0.18);\r\n        }\r\n\r\n        #shl-roue-wp .toggle-btn[data-mode=\"none\"].active{ background: #24476d; }\r\n        #shl-roue-wp .toggle-btn[data-mode=\"category\"].active{ background: #d39836; }\r\n\r\n        \/* Interrupteur d'affichage des associations : la roue d\u00e9marre vide d'assos,\r\n           ce switch on\/off r\u00e9v\u00e8le les bulles de comptage. *\/\r\n        #shl-roue-wp .assos-switch-group{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            justify-content: space-between;\r\n            gap: 0.7rem;\r\n            padding: 0.6rem 0.75rem;\r\n            border-radius: 18px;\r\n            background: var(--chip-bg);\r\n            box-shadow: var(--chip-shadow);\r\n            border: 1px solid rgba(231, 223, 217, 0.9);\r\n            width: fit-content;\r\n            cursor: pointer;\r\n            font: inherit;\r\n            color: var(--text-color);\r\n            transition: border-color 0.2s ease, transform 0.15s ease;\r\n        }\r\n        #shl-roue-wp .assos-switch-group:hover{\r\n            border-color: rgba(36, 71, 109, 0.28);\r\n            transform: translateY(-1px);\r\n        }\r\n        #shl-roue-wp .assos-switch-label{\r\n            font-size: 0.82rem;\r\n            font-weight: 700;\r\n            white-space: nowrap;\r\n        }\r\n        #shl-roue-wp .assos-switch{\r\n            position: relative;\r\n            flex: none;\r\n            width: 38px;\r\n            height: 22px;\r\n            border-radius: 999px;\r\n            background: rgba(36, 71, 109, 0.2);\r\n            transition: background-color 0.22s ease;\r\n        }\r\n        #shl-roue-wp .assos-switch-thumb{\r\n            position: absolute;\r\n            top: 2px;\r\n            left: 2px;\r\n            width: 18px;\r\n            height: 18px;\r\n            border-radius: 50%;\r\n            background: #ffffff;\r\n            box-shadow: 0 2px 5px rgba(15, 23, 42, 0.28);\r\n            transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);\r\n        }\r\n        #shl-roue-wp .assos-switch-group.active .assos-switch{\r\n            background: #2f8d69;\r\n        }\r\n        #shl-roue-wp .assos-switch-group.active .assos-switch-thumb{\r\n            transform: translateX(16px);\r\n        }\r\n\r\n        \/* Prompt flottant : appara\u00eet quand l'utilisateur clique un th\u00e8me\/cat\u00e9gorie sur la roue\r\n           sans ouvrir automatiquement la sidebar. Au clic, on d\u00e9clenche l'ouverture. *\/\r\n        #shl-roue-wp .detail-prompt{\r\n            position: fixed;\r\n            top: 1.3rem;\r\n            right: 1.3rem;\r\n            z-index: 30;\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 0.85rem;\r\n            padding: 0.65rem 0.95rem 0.65rem 0.7rem;\r\n            border-radius: 999px;\r\n            background: rgba(255, 255, 255, 0.96);\r\n            backdrop-filter: blur(10px);\r\n            -webkit-backdrop-filter: blur(10px);\r\n            border: 1px solid rgba(36, 71, 109, 0.2);\r\n            box-shadow: 0 18px 38px rgba(36, 71, 109, 0.18);\r\n            color: var(--text-color);\r\n            font: inherit;\r\n            text-align: left;\r\n            cursor: pointer;\r\n            opacity: 0;\r\n            transform: translateY(-12px) scale(0.96);\r\n            pointer-events: none;\r\n            transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1),\r\n                        transform 0.32s cubic-bezier(0.22, 1, 0.36, 1),\r\n                        box-shadow 0.2s ease,\r\n                        border-color 0.2s ease,\r\n                        right 0.42s cubic-bezier(0.22, 1, 0.36, 1),\r\n                        bottom 0.42s cubic-bezier(0.22, 1, 0.36, 1),\r\n                        top 0.42s cubic-bezier(0.22, 1, 0.36, 1);\r\n        }\r\n        \/* Quand la sidebar est ouverte, on d\u00e9cale le prompt pour qu'il reste\r\n           au-dessus du chart, pas par-dessus la sidebar. *\/\r\n        body.sidebar-open #shl-roue-wp .detail-prompt{\r\n            right: calc(clamp(340px, 34vw, 450px) + 1.3rem);\r\n        }\r\n        #shl-roue-wp .detail-prompt.visible{\r\n            opacity: 1;\r\n            transform: translateY(0) scale(1);\r\n            pointer-events: auto;\r\n        }\r\n        #shl-roue-wp .detail-prompt:hover{\r\n            border-color: rgba(36, 71, 109, 0.42);\r\n            box-shadow: 0 22px 48px rgba(36, 71, 109, 0.26);\r\n        }\r\n        #shl-roue-wp .detail-prompt-icon{\r\n            width: 36px;\r\n            height: 36px;\r\n            border-radius: 50%;\r\n            background: var(--prompt-color, var(--accent-color, #6d4f84));\r\n            color: white;\r\n            display: inline-flex;\r\n            align-items: center;\r\n            justify-content: center;\r\n            font-size: 1rem;\r\n            font-weight: 800;\r\n            flex-shrink: 0;\r\n            box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);\r\n        }\r\n        #shl-roue-wp .detail-prompt-text{\r\n            display: flex;\r\n            flex-direction: column;\r\n            min-width: 0;\r\n            max-width: 200px;\r\n        }\r\n        #shl-roue-wp .detail-prompt-eyebrow{\r\n            font-size: 0.6rem;\r\n            font-weight: 800;\r\n            text-transform: uppercase;\r\n            letter-spacing: 0.1em;\r\n            color: var(--muted-text, #6d7580);\r\n            line-height: 1.2;\r\n        }\r\n        #shl-roue-wp .detail-prompt-title{\r\n            font-size: 0.88rem;\r\n            font-weight: 800;\r\n            color: var(--text-color);\r\n            line-height: 1.2;\r\n            margin-top: 1px;\r\n            overflow: hidden;\r\n            text-overflow: ellipsis;\r\n            white-space: nowrap;\r\n        }\r\n        #shl-roue-wp .detail-prompt-cta{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 0.25rem;\r\n            font-size: 0.75rem;\r\n            font-weight: 800;\r\n            color: var(--prompt-color, var(--accent-color, #6d4f84));\r\n            padding: 0.3rem 0.55rem 0.3rem 0.65rem;\r\n            background: rgba(36, 71, 109, 0.06);\r\n            border-radius: 999px;\r\n            white-space: nowrap;\r\n        }\r\n        #shl-roue-wp .detail-prompt-close{\r\n            background: transparent;\r\n            border: none;\r\n            color: var(--muted-text, #6d7580);\r\n            font-size: 1rem;\r\n            line-height: 1;\r\n            cursor: pointer;\r\n            padding: 0.2rem 0.35rem;\r\n            border-radius: 50%;\r\n            margin-left: -0.15rem;\r\n            transition: background-color 0.15s ease, color 0.15s ease;\r\n        }\r\n        #shl-roue-wp .detail-prompt-close:hover{ background: rgba(36, 71, 109, 0.08); color: var(--text-color); }\r\n\r\n        @media (max-width: 640px) {\r\n            #shl-roue-wp .detail-prompt{\r\n                top: auto;\r\n                bottom: 1rem;\r\n                left: 50%;\r\n                right: auto;\r\n                transform: translate(-50%, 16px) scale(0.96);\r\n                max-width: calc(100vw - 1.5rem);\r\n            }\r\n            #shl-roue-wp .detail-prompt.visible{ transform: translate(-50%, 0) scale(1); }\r\n            #shl-roue-wp .detail-prompt-text{ max-width: 150px; }\r\n        }\r\n\r\n        #shl-roue-wp .main-content{ display: flex; flex: 1; position: relative; overflow: hidden; }\r\n\r\n        #shl-roue-wp #chart-container{\r\n            flex: 1;\r\n            display: flex;\r\n            justify-content: center;\r\n            align-items: center;\r\n            position: relative;\r\n            padding: 1.5rem 1.25rem 1.25rem;\r\n            transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);\r\n            contain: layout style;\r\n        }\r\n        #shl-roue-wp #chart-container svg{\r\n            width: 100%;\r\n            height: 100%;\r\n            display: block;\r\n            touch-action: manipulation;\r\n        }\r\n\r\n        \/* v4 \u2014 L\u00e9gende verticale des programmes europ\u00e9ens. Une fine colonne \u00e0\r\n           droite de la roue : pastille color\u00e9e + nom, empil\u00e9s verticalement,\r\n           regroup\u00e9s par cat\u00e9gorie. Appara\u00eet uniquement quand au moins une\r\n           cat\u00e9gorie de financement est active. *\/\r\n        #shl-roue-wp .funding-legend{\r\n            display: none;\r\n            \/* L\u00e9gende un peu plus fine : lib\u00e8re de la largeur pour la roue, surtout\r\n               sur laptop o\u00f9 l'espace horizontal est compt\u00e9. *\/\r\n            flex: 0 0 clamp(168px, 14vw, 250px);\r\n            flex-direction: column;\r\n            align-self: center;\r\n            max-height: calc(100% - 2rem);\r\n            margin: 0 0.6rem 0 0.35rem;\r\n            padding: 0.5rem 0.5rem 0.5rem 0;\r\n            overflow-y: auto;\r\n            scrollbar-width: none;\r\n            -ms-overflow-style: none;\r\n        }\r\n        #shl-roue-wp .funding-legend::-webkit-scrollbar{ display: none; }\r\n        #shl-roue-wp .funding-legend.is-active{\r\n            display: flex;\r\n            animation: fundingLegendIn 0.34s cubic-bezier(0.22, 1, 0.36, 1);\r\n        }\r\n        @keyframes fundingLegendIn{\r\n            from { opacity: 0; transform: translateX(10px); }\r\n            to { opacity: 1; transform: translateX(0); }\r\n        }\r\n        #shl-roue-wp .funding-legend-cat{\r\n            margin-bottom: 1.15rem;\r\n        }\r\n        #shl-roue-wp .funding-legend-cat:last-child{ margin-bottom: 0; }\r\n        #shl-roue-wp .funding-legend-cat-title{\r\n            margin: 0 0 0.45rem;\r\n            font-size: 0.72rem;\r\n            font-weight: 850;\r\n            letter-spacing: 0.08em;\r\n            text-transform: uppercase;\r\n            color: var(--muted-text);\r\n        }\r\n        #shl-roue-wp .funding-legend-list{\r\n            list-style: none;\r\n            margin: 0;\r\n            padding: 0;\r\n            display: flex;\r\n            flex-direction: column;\r\n            gap: 0.42rem;\r\n        }\r\n        #shl-roue-wp .funding-legend-item{\r\n            display: flex;\r\n            align-items: baseline;\r\n            gap: 0.65rem;\r\n            font-size: 0.9rem;\r\n            font-weight: 650;\r\n            line-height: 1.3;\r\n            color: var(--text-color);\r\n            overflow-wrap: anywhere;\r\n        }\r\n        #shl-roue-wp .funding-legend-dot{\r\n            flex: none;\r\n            width: 10px;\r\n            height: 10px;\r\n            border-radius: 999px;\r\n            background: var(--dot-color, currentColor);\r\n            transform: translateY(2px);\r\n        }\r\n        @media (max-width: 900px){\r\n            #shl-roue-wp .main-content{ flex-direction: column; }\r\n            #shl-roue-wp .funding-legend{\r\n                flex: 0 0 auto;\r\n                align-self: stretch;\r\n                max-height: 36vh;\r\n                margin: 0 0.8rem 0.6rem;\r\n                padding: 0.7rem 0.9rem 0.9rem;\r\n            }\r\n            #shl-roue-wp .funding-legend-item{\r\n                font-size: 0.84rem;\r\n            }\r\n        }\r\n\r\n        #shl-roue-wp path.arc{\r\n            cursor: pointer;\r\n            transition: opacity 0.3s ease-out;\r\n            shape-rendering: geometricPrecision;\r\n            vector-effect: non-scaling-stroke;\r\n            stroke: rgba(255, 255, 255, 0.9);\r\n            stroke-width: 1.6px;\r\n        }\r\n        #shl-roue-wp path.arc:hover{ opacity: 0.85 !important; }\r\n\r\n        #shl-roue-wp path.funding-arc{\r\n            transition: opacity 0.3s ease-out;\r\n            stroke: #ffffff;\r\n            stroke-width: 1.8px;\r\n            vector-effect: non-scaling-stroke;\r\n            cursor: help;\r\n        }\r\n\r\n        #shl-roue-wp .arcs, #shl-roue-wp .assos, #shl-roue-wp .texts{\r\n            will-change: transform;\r\n        }\r\n\r\n        #shl-roue-wp .sub-bubble{\r\n            cursor: pointer;\r\n            transition: opacity 0.25s ease-out;\r\n        }\r\n        #shl-roue-wp .sub-bubble-circle{\r\n            stroke: rgba(255, 255, 255, 0.9);\r\n            stroke-width: 2px;\r\n            vector-effect: non-scaling-stroke;\r\n            transition: stroke-width 0.2s cubic-bezier(0.4, 0, 0.2, 1), r 0.25s ease-out;\r\n            filter: drop-shadow(0 4px 9px rgba(15, 23, 42, 0.22));\r\n        }\r\n        #shl-roue-wp .sub-bubble:hover .sub-bubble-circle{\r\n            stroke-width: 3.5px;\r\n        }\r\n        #shl-roue-wp .sub-bubble-count{\r\n            pointer-events: none;\r\n            font-family: var(--font-family);\r\n            font-weight: 800;\r\n            text-anchor: middle;\r\n            dominant-baseline: central;\r\n            fill: #ffffff;\r\n        }\r\n\r\n        #shl-roue-wp .sub-bubble-inner{\r\n            transform: scale(0);\r\n            transform-origin: 0 0;\r\n            transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);\r\n        }\r\n        #shl-roue-wp .sub-bubble-inner.popped{\r\n            transform: scale(1);\r\n        }\r\n\r\n        \/* legacy classes kept for the funding overlay markup *\/\r\n        #shl-roue-wp .association-marker, #shl-roue-wp .association-leader, #shl-roue-wp .association-dot, #shl-roue-wp .association-label{\r\n            display: none;\r\n        }\r\n\r\n        #shl-roue-wp .bubble-back-btn{\r\n            background: none;\r\n            border: none;\r\n            font-family: var(--font-family);\r\n            font-size: 0.85rem;\r\n            color: var(--muted-text, #6d7580);\r\n            cursor: pointer;\r\n            padding: 0;\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 4px;\r\n            line-height: 1.35;\r\n        }\r\n        #shl-roue-wp .bubble-back-btn:hover{ color: var(--accent-color, #6d4f84); }\r\n\r\n        #shl-roue-wp .bubble-asso-list{\r\n            display: flex;\r\n            flex-direction: column;\r\n            gap: 0.6rem;\r\n            margin-top: 1rem;\r\n        }\r\n        #shl-roue-wp .bubble-asso-card{\r\n            background: white;\r\n            border: 1px solid var(--border-color, #e7dfd9);\r\n            border-radius: 12px;\r\n            padding: 0.85rem 1rem;\r\n            cursor: pointer;\r\n            transition: border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;\r\n            font-family: inherit;\r\n            text-align: left;\r\n            display: flex;\r\n            flex-direction: column;\r\n            gap: 2px;\r\n        }\r\n        #shl-roue-wp .bubble-asso-card:hover{\r\n            border-color: var(--accent-color, #6d4f84);\r\n            transform: translateY(-1px);\r\n            box-shadow: 0 6px 14px rgba(36, 71, 109, 0.07);\r\n        }\r\n        #shl-roue-wp .bubble-asso-name{\r\n            font-weight: 700;\r\n            font-size: 0.95rem;\r\n            color: var(--text-color, #24476d);\r\n        }\r\n        #shl-roue-wp .bubble-asso-zone{\r\n            font-size: 0.78rem;\r\n            color: var(--muted-text, #6d7580);\r\n        }\r\n\r\n        #shl-roue-wp text.label{\r\n            pointer-events: none;\r\n            font-family: var(--font-family);\r\n            \/* Rendu UNIFORME du texte sur tous les niveaux de la roue : m\u00eame\r\n               graisse + m\u00eame contour sombre (lisible sur les arcs clairs comme\r\n               fonc\u00e9s). Cat\u00e9gories, sous-cat\u00e9gories et titre de th\u00e8me partagent\r\n               ces valeurs (cf. .item-label \/ .domain-title-text plus bas). *\/\r\n            font-weight: 750;\r\n            fill: #ffffff;\r\n            text-anchor: middle;\r\n            dominant-baseline: middle;\r\n            text-rendering: geometricPrecision;\r\n            paint-order: stroke fill;\r\n            \/* Contour sombre renforc\u00e9 : le texte blanc restait peu lisible sur\r\n               les arcs bleu clair. Halo plus opaque + un peu plus \u00e9pais. *\/\r\n            stroke: rgba(15, 26, 37, 0.9);\r\n            stroke-width: 2.6px;\r\n            stroke-linejoin: round;\r\n            letter-spacing: 0.005em;\r\n            transition: opacity 0.5s ease, font-size 0.6s cubic-bezier(0.4, 0, 0.2, 1);\r\n        }\r\n\r\n        #shl-roue-wp text.label.domain-label{\r\n            font-weight: 740;\r\n            letter-spacing: 0;\r\n            stroke: rgba(15, 28, 40, 0.12);\r\n            stroke-width: 1.1px;\r\n        }\r\n\r\n        #shl-roue-wp text.domain-title-text{\r\n            pointer-events: none;\r\n            font-family: var(--font-family);\r\n            font-style: normal;\r\n            font-weight: 850;\r\n            letter-spacing: 0.01em;\r\n            fill: #ffffff;\r\n            text-anchor: middle;\r\n            dominant-baseline: middle;\r\n            text-rendering: geometricPrecision;\r\n            paint-order: stroke fill;\r\n            stroke: rgba(17, 28, 39, 0.55);\r\n            stroke-width: 2.1px;\r\n        }\r\n\r\n        \/* Cat\u00e9gories (depth-2) : m\u00eames valeurs que la base \u2192 rendu identique\r\n           \u00e0 celui des sous-cat\u00e9gories. *\/\r\n        #shl-roue-wp text.label.item-label{\r\n            font-weight: 750;\r\n            stroke-width: 2.1px;\r\n        }\r\n\r\n        #shl-roue-wp text.center-text{\r\n            pointer-events: none;\r\n            font-family: var(--font-family);\r\n            font-weight: 700;\r\n            fill: var(--text-color);\r\n            text-anchor: middle;\r\n            transition: opacity 0.5s ease;\r\n        }\r\n\r\n        #shl-roue-wp .center-control circle{\r\n            cursor: pointer;\r\n            stroke: rgba(36, 71, 109, 0.12);\r\n            stroke-width: 1.5px;\r\n            filter: drop-shadow(0 10px 20px rgba(36, 71, 109, 0.12));\r\n            transition: fill 0.2s ease, stroke 0.2s ease, filter 0.2s ease;\r\n        }\r\n\r\n        #shl-roue-wp .center-control{\r\n            outline: none;\r\n        }\r\n\r\n        #shl-roue-wp .center-control:hover circle{\r\n            fill: #f8fafc;\r\n            stroke: rgba(36, 71, 109, 0.3);\r\n            filter: drop-shadow(0 12px 24px rgba(36, 71, 109, 0.18));\r\n        }\r\n\r\n        #shl-roue-wp .center-control:focus circle{\r\n            stroke: rgba(36, 71, 109, 0.42);\r\n        }\r\n\r\n        #shl-roue-wp text.center-back-icon, #shl-roue-wp text.center-back-label{\r\n            pointer-events: none;\r\n            font-family: var(--font-family);\r\n            fill: var(--text-color);\r\n            text-anchor: middle;\r\n            dominant-baseline: middle;\r\n            opacity: 0;\r\n            transition: opacity 0.25s ease;\r\n        }\r\n\r\n        #shl-roue-wp text.center-back-icon{\r\n            font-size: 22px;\r\n            font-weight: 900;\r\n        }\r\n\r\n        #shl-roue-wp text.center-back-label{\r\n            font-size: 11px;\r\n            font-weight: 800;\r\n        }\r\n\r\n        #shl-roue-wp .sidebar{\r\n            position: absolute;\r\n            top: 0;\r\n            right: 0;\r\n            width: clamp(340px, 34vw, 450px);\r\n            height: 100%;\r\n            background: var(--sidebar-bg);\r\n            backdrop-filter: blur(16px);\r\n            -webkit-backdrop-filter: blur(16px);\r\n            border-left: 1px solid var(--border-color);\r\n            box-shadow: -18px 0 45px rgba(36, 71, 109, 0.12);\r\n            transform: translateX(0) scale(1);\r\n            transform-origin: right center;\r\n            transition:\r\n                transform 0.48s cubic-bezier(0.22, 1, 0.36, 1),\r\n                opacity 0.32s ease,\r\n                box-shadow 0.32s ease;\r\n            display: flex;\r\n            flex-direction: column;\r\n            z-index: 2147483400;\r\n            isolation: isolate;\r\n            will-change: transform, opacity;\r\n        }\r\n\r\n        #shl-roue-wp .sidebar.hidden{ transform: translateX(108%) scale(0.985); opacity: 0; pointer-events: none; box-shadow: -8px 0 18px rgba(36, 71, 109, 0.02); }\r\n\r\n        #shl-roue-wp .close-btn{\r\n            position: absolute;\r\n            top: 1rem; right: 1.5rem;\r\n            background: white;\r\n            border: 1px solid #cbd5e1;\r\n            color: var(--muted-text);\r\n            width: 32px; height: 32px;\r\n            border-radius: 50%;\r\n            font-size: 1.2rem;\r\n            display: flex;\r\n            align-items: center;\r\n            justify-content: center;\r\n            cursor: pointer;\r\n            transition: all 0.2s;\r\n            box-shadow: 0 2px 4px rgba(0,0,0,0.05);\r\n            z-index: 30;\r\n            pointer-events: auto;\r\n            touch-action: manipulation;\r\n        }\r\n        #shl-roue-wp .close-btn:hover{ background: #f1f5f9; color: #0f172a; transform: rotate(90deg); }\r\n\r\n        #shl-roue-wp .sidebar-back-btn{\r\n            position: absolute;\r\n            top: 1rem;\r\n            left: 1rem;\r\n            right: auto;\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 0.35rem;\r\n            background: rgba(255, 255, 255, 0.96);\r\n            border: 1px solid #cbd5e1;\r\n            color: var(--text-color);\r\n            padding: 0.42rem 0.75rem 0.42rem 0.6rem;\r\n            border-radius: 999px;\r\n            font: inherit;\r\n            font-size: 0.74rem;\r\n            font-weight: 700;\r\n            cursor: pointer;\r\n            box-shadow: 0 2px 6px rgba(0,0,0,0.06);\r\n            transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;\r\n            z-index: 6;\r\n            backdrop-filter: blur(6px);\r\n            -webkit-backdrop-filter: blur(6px);\r\n        }\r\n        #shl-roue-wp .sidebar-back-btn:hover{\r\n            background: #f1f5f9;\r\n            border-color: rgba(36, 71, 109, 0.32);\r\n            transform: translateX(-2px) translateY(-1px);\r\n            box-shadow: 0 8px 18px rgba(36, 71, 109, 0.12);\r\n        }\r\n        #shl-roue-wp .sidebar-back-btn[hidden]{ display: none !important; }\r\n        #shl-roue-wp .sidebar-back-icon{ font-size: 0.95rem; line-height: 1; font-weight: 800; }\r\n        #shl-roue-wp .sidebar-back-label{ letter-spacing: 0.01em; }\r\n\r\n        \/* padding-top \u00e9largi pour que le contenu (vue cat\u00e9gorie\/sous-liste) passe\r\n           SOUS les boutons Retour + Fermer qui flottent en absolu. La vue profil\r\n           annule ce padding via la marge n\u00e9gative de .profile-hero (valeurs li\u00e9es). *\/\r\n        #shl-roue-wp .sidebar-content{ padding: 3.7rem 2.5rem 3rem; overflow-y: auto; flex: 1; scroll-behavior: smooth; }\r\n\r\n        #shl-roue-wp .sidebar-content.is-entering{\r\n            animation: sidebarContentEnter 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;\r\n        }\r\n\r\n        @keyframes sidebarContentEnter {\r\n            from { opacity: 0; transform: translateY(10px) scale(0.992); }\r\n            to { opacity: 1; transform: translateY(0) scale(1); }\r\n        }\r\n\r\n        #shl-roue-wp .asso-header-block{\r\n            padding-right: 2.5rem;\r\n            margin-bottom: 1.15rem;\r\n        }\r\n\r\n        #shl-roue-wp .sidebar-top-action{\r\n            display: block;\r\n            margin-bottom: 0.85rem;\r\n        }\r\n\r\n        #shl-roue-wp .tag-domain{\r\n            display: inline-flex;\r\n            max-width: 100%;\r\n            padding: 0.35rem 0.75rem;\r\n            border-radius: 10px;\r\n            font-size: 0.75rem;\r\n            font-weight: 800;\r\n            letter-spacing: 0.02em;\r\n            line-height: 1.25;\r\n            margin-bottom: 1rem;\r\n            color: white;\r\n            overflow-wrap: anywhere;\r\n            white-space: normal;\r\n        }\r\n\r\n        #shl-roue-wp .asso-title{\r\n            font-size: 2.2rem;\r\n            font-weight: 700;\r\n            margin-bottom: 0.5rem;\r\n            line-height: 1.1;\r\n            color: #13293f;\r\n            letter-spacing: -0.02em;\r\n        }\r\n\r\n        #shl-roue-wp .asso-theme{\r\n            font-size: 1rem;\r\n            color: var(--muted-text);\r\n            font-weight: 500;\r\n            margin-bottom: 2rem;\r\n            display: flex;\r\n            align-items: center;\r\n            gap: 0.5rem;\r\n            flex-wrap: wrap;\r\n        }\r\n\r\n        #shl-roue-wp .sub-total-raised{\r\n            display: flex;\r\n            justify-content: space-between;\r\n            align-items: center;\r\n            gap: 1rem;\r\n            background: linear-gradient(135deg, rgba(46, 128, 98, 0.10), rgba(46, 128, 98, 0.04));\r\n            border: 1px solid rgba(46, 128, 98, 0.28);\r\n            border-radius: 14px;\r\n            padding: 0.95rem 1.15rem;\r\n            margin: 0 0 0.6rem 0;\r\n        }\r\n        #shl-roue-wp .sub-total-raised + .sub-total-raised{\r\n            margin-top: 0;\r\n        }\r\n        #shl-roue-wp .sub-total-raised:last-of-type{\r\n            margin-bottom: 1.6rem;\r\n        }\r\n        #shl-roue-wp .sub-total-raised--requested{\r\n            background: linear-gradient(135deg, rgba(109, 79, 132, 0.10), rgba(109, 79, 132, 0.04));\r\n            border-color: rgba(109, 79, 132, 0.28);\r\n        }\r\n        #shl-roue-wp .sub-total-raised--requested .label{\r\n            color: #6d4f84;\r\n        }\r\n        #shl-roue-wp .sub-total-raised.is-empty{\r\n            background: rgba(148, 163, 184, 0.06);\r\n            border-color: rgba(148, 163, 184, 0.22);\r\n        }\r\n        #shl-roue-wp .sub-total-raised.is-empty .label{\r\n            color: #94a3b8;\r\n        }\r\n        #shl-roue-wp .sub-total-raised.is-empty .value{\r\n            color: #94a3b8;\r\n            font-weight: 600;\r\n        }\r\n        #shl-roue-wp .sub-total-raised.is-empty .sub{\r\n            color: #94a3b8;\r\n            font-style: italic;\r\n        }\r\n        #shl-roue-wp .sub-total-raised > div:first-child{\r\n            flex: 1 1 auto;\r\n            min-width: 0;\r\n            display: flex;\r\n            flex-direction: column;\r\n            justify-content: center;\r\n            gap: 0.15rem;\r\n        }\r\n        #shl-roue-wp .sub-total-raised .label{\r\n            font-size: 0.72rem;\r\n            font-weight: 800;\r\n            text-transform: uppercase;\r\n            letter-spacing: 0.1em;\r\n            color: #2e8062;\r\n            line-height: 1.2;\r\n        }\r\n        #shl-roue-wp .sub-total-raised .value{\r\n            font-size: 1.55rem;\r\n            font-weight: 800;\r\n            color: #13293f;\r\n            letter-spacing: -0.02em;\r\n            white-space: nowrap;\r\n            flex: 0 0 auto;\r\n            line-height: 1.1;\r\n            align-self: center;\r\n        }\r\n        #shl-roue-wp .sub-total-raised .sub{\r\n            font-size: 0.7rem;\r\n            color: var(--muted-text, #64748b);\r\n            font-weight: 600;\r\n            line-height: 1.3;\r\n        }\r\n\r\n        #shl-roue-wp .asso-summary{\r\n            display: grid;\r\n            gap: 0.62rem;\r\n            margin: 0.9rem 0 1.6rem;\r\n            padding: 0.95rem 0;\r\n            border-top: 1px solid #e2e8f0;\r\n            border-bottom: 1px solid #e2e8f0;\r\n        }\r\n\r\n        #shl-roue-wp .summary-row{\r\n            display: grid;\r\n            grid-template-columns: minmax(110px, 0.34fr) minmax(0, 1fr);\r\n            gap: 0.75rem;\r\n            align-items: start;\r\n        }\r\n\r\n        #shl-roue-wp .summary-label{\r\n            color: #94a3b8;\r\n            font-size: 0.7rem;\r\n            font-weight: 850;\r\n            letter-spacing: 0.06em;\r\n            line-height: 1.35;\r\n            text-transform: uppercase;\r\n        }\r\n\r\n        #shl-roue-wp .summary-value{\r\n            color: #324456;\r\n            font-size: 0.95rem;\r\n            font-weight: 700;\r\n            line-height: 1.42;\r\n            overflow-wrap: anywhere;\r\n        }\r\n\r\n        #shl-roue-wp .description-block{\r\n            margin: 0 0 1.8rem;\r\n            padding: 0 0 1.55rem;\r\n            border-bottom: 1px solid #e2e8f0;\r\n        }\r\n\r\n        #shl-roue-wp .section-title{\r\n            color: #94a3b8;\r\n            font-size: 0.72rem;\r\n            font-weight: 850;\r\n            letter-spacing: 0.06em;\r\n            margin-bottom: 0.55rem;\r\n            text-transform: uppercase;\r\n        }\r\n\r\n        #shl-roue-wp .asso-desc{\r\n            font-size: 1.05rem;\r\n            line-height: 1.6;\r\n            color: #445463;\r\n            margin: 0;\r\n            white-space: pre-wrap;\r\n            overflow-wrap: anywhere;\r\n        }\r\n\r\n        #shl-roue-wp .asso-detail-grid{\r\n            display: grid;\r\n            gap: 0.75rem;\r\n            margin: 0 0 1.8rem;\r\n            padding-bottom: 1.8rem;\r\n            border-bottom: 1px solid #e2e8f0;\r\n        }\r\n\r\n        #shl-roue-wp .detail-row{\r\n            display: grid;\r\n            grid-template-columns: minmax(120px, 0.38fr) minmax(0, 1fr);\r\n            gap: 0.8rem;\r\n            align-items: start;\r\n        }\r\n\r\n        #shl-roue-wp .detail-label{\r\n            color: #94a3b8;\r\n            font-size: 0.72rem;\r\n            font-weight: 800;\r\n            letter-spacing: 0.06em;\r\n            line-height: 1.35;\r\n            text-transform: uppercase;\r\n        }\r\n\r\n        #shl-roue-wp .detail-value{\r\n            color: #445463;\r\n            font-size: 0.94rem;\r\n            font-weight: 600;\r\n            line-height: 1.45;\r\n            overflow-wrap: anywhere;\r\n            white-space: pre-wrap;\r\n        }\r\n\r\n        #shl-roue-wp .status-pill{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            width: fit-content;\r\n            border-radius: 999px;\r\n            padding: 0.28rem 0.65rem;\r\n            background: #edf7f2;\r\n            color: #276749;\r\n            font-size: 0.78rem;\r\n            font-weight: 800;\r\n        }\r\n\r\n        #shl-roue-wp .status-pill.is-muted{\r\n            background: #f1f5f9;\r\n            color: #64748b;\r\n        }\r\n\r\n        #shl-roue-wp .asso-contact{ margin-top: 2rem; }\r\n        #shl-roue-wp .asso-contact h4{\r\n            font-size: 0.9rem;\r\n            text-transform: uppercase;\r\n            letter-spacing: 0.05em;\r\n            color: #94a3b8;\r\n            margin-bottom: 1rem;\r\n        }\r\n        #shl-roue-wp .contact-link{\r\n            display: flex;\r\n            align-items: center;\r\n            gap: 0.5rem;\r\n            color: var(--accent-color);\r\n            text-decoration: none;\r\n            font-weight: 600;\r\n            transition: opacity 0.2s;\r\n            margin-bottom: 0.75rem;\r\n        }\r\n        #shl-roue-wp .contact-link:hover{ opacity: 0.8; text-decoration: underline; }\r\n\r\n        \/* ============== Asso profile (option A) ============== *\/\r\n        #shl-roue-wp .profile-card{\r\n            --profile-color: #6d4f84;\r\n            --profile-color-soft: rgba(109, 79, 132, 0.08);\r\n            --profile-color-mid: rgba(109, 79, 132, 0.22);\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero{\r\n            position: relative;\r\n            margin: -3.7rem -2.5rem 1.5rem;\r\n            padding: 3.55rem 2.5rem 1.7rem;\r\n            background:\r\n                linear-gradient(135deg,\r\n                    var(--profile-color),\r\n                    color-mix(in srgb, var(--profile-color) 70%, #14202d) 100%);\r\n            color: #ffffff;\r\n            overflow: hidden;\r\n            isolation: isolate;\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero::before, #shl-roue-wp .profile-hero::after{\r\n            content: \"\";\r\n            position: absolute;\r\n            border-radius: 50%;\r\n            pointer-events: none;\r\n            z-index: 0;\r\n        }\r\n        #shl-roue-wp .profile-hero::before{\r\n            top: -55%;\r\n            right: -22%;\r\n            width: 75%;\r\n            height: 200%;\r\n            background: radial-gradient(circle, rgba(255, 255, 255, 0.22), transparent 62%);\r\n        }\r\n        #shl-roue-wp .profile-hero::after{\r\n            bottom: -45%;\r\n            left: -10%;\r\n            width: 50%;\r\n            height: 140%;\r\n            background: radial-gradient(circle, rgba(0, 0, 0, 0.18), transparent 65%);\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero > *{\r\n            position: relative;\r\n            z-index: 1;\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero .sidebar-top-action{\r\n            margin-bottom: 1rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero .bubble-back-btn{\r\n            color: rgba(255, 255, 255, 0.85);\r\n            font-weight: 600;\r\n        }\r\n        #shl-roue-wp .profile-hero .bubble-back-btn:hover{ color: #ffffff; }\r\n\r\n        #shl-roue-wp .profile-hero-domain{\r\n            display: inline-flex;\r\n            padding: 0.34rem 0.78rem;\r\n            border-radius: 999px;\r\n            background: rgba(255, 255, 255, 0.18);\r\n            border: 1px solid rgba(255, 255, 255, 0.3);\r\n            color: #ffffff;\r\n            font-size: 0.7rem;\r\n            font-weight: 800;\r\n            letter-spacing: 0.07em;\r\n            text-transform: uppercase;\r\n            margin-bottom: 0.95rem;\r\n            backdrop-filter: blur(6px);\r\n            -webkit-backdrop-filter: blur(6px);\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero-title{\r\n            font-size: 2rem;\r\n            font-weight: 800;\r\n            line-height: 1.08;\r\n            letter-spacing: -0.02em;\r\n            margin: 0 0 0.85rem;\r\n            color: #ffffff;\r\n            overflow-wrap: anywhere;\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero-meta{\r\n            display: flex;\r\n            flex-wrap: wrap;\r\n            align-items: center;\r\n            gap: 0.5rem;\r\n            color: rgba(255, 255, 255, 0.92);\r\n            font-size: 0.83rem;\r\n            font-weight: 600;\r\n            line-height: 1.4;\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero-zone{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 0.32rem;\r\n            background: rgba(255, 255, 255, 0.18);\r\n            border: 1px solid rgba(255, 255, 255, 0.22);\r\n            padding: 0.25rem 0.6rem;\r\n            border-radius: 999px;\r\n            font-weight: 700;\r\n        }\r\n\r\n        #shl-roue-wp .profile-hero-sep{\r\n            color: rgba(255, 255, 255, 0.45);\r\n            font-weight: 700;\r\n        }\r\n\r\n        #shl-roue-wp .profile-pills{\r\n            display: grid;\r\n            grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));\r\n            gap: 0.55rem;\r\n            margin: 0 0 1.6rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-pill{\r\n            background: var(--profile-color-soft);\r\n            border: 1px solid var(--profile-color-mid);\r\n            border-radius: 12px;\r\n            padding: 0.65rem 0.78rem;\r\n            display: flex;\r\n            flex-direction: column;\r\n            gap: 0.22rem;\r\n            min-width: 0;\r\n        }\r\n\r\n        #shl-roue-wp .profile-pill-label{\r\n            font-size: 0.65rem;\r\n            font-weight: 800;\r\n            letter-spacing: 0.08em;\r\n            text-transform: uppercase;\r\n            color: var(--profile-color);\r\n            opacity: 0.92;\r\n        }\r\n\r\n        #shl-roue-wp .profile-pill-value{\r\n            font-size: 0.92rem;\r\n            font-weight: 700;\r\n            color: #1f2d3d;\r\n            line-height: 1.32;\r\n            overflow-wrap: anywhere;\r\n        }\r\n\r\n        #shl-roue-wp .profile-pill.is-positive{\r\n            background: #ecf7f1;\r\n            border-color: #b8dec8;\r\n        }\r\n        #shl-roue-wp .profile-pill.is-positive .profile-pill-label{ color: #207a4f; }\r\n        #shl-roue-wp .profile-pill.is-positive .profile-pill-value{ color: #14512f; }\r\n\r\n        #shl-roue-wp .profile-section{\r\n            margin: 0 0 1.6rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-section-eyebrow{\r\n            color: #94a3b8;\r\n            font-size: 0.7rem;\r\n            font-weight: 800;\r\n            letter-spacing: 0.08em;\r\n            text-transform: uppercase;\r\n            margin-bottom: 0.6rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-description{\r\n            font-size: 1.04rem;\r\n            line-height: 1.62;\r\n            color: #2f3e4f;\r\n            margin: 0;\r\n            white-space: pre-wrap;\r\n            overflow-wrap: anywhere;\r\n            font-weight: 500;\r\n            border-left: 3px solid var(--profile-color);\r\n            padding: 0.1rem 0 0.1rem 0.95rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-funding-chips{\r\n            display: flex;\r\n            flex-wrap: wrap;\r\n            gap: 0.42rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-funding-chip{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            padding: 0.42rem 0.8rem;\r\n            border-radius: 999px;\r\n            background: var(--profile-color-soft);\r\n            color: var(--profile-color);\r\n            font-size: 0.83rem;\r\n            font-weight: 700;\r\n            border: 1px solid var(--profile-color-mid);\r\n            letter-spacing: 0.005em;\r\n        }\r\n\r\n        #shl-roue-wp .profile-funding-summary{\r\n            display: grid;\r\n            gap: 0.5rem;\r\n            margin-top: 0.7rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-funding-row{\r\n            padding: 0.72rem 0.85rem;\r\n            border-radius: 12px;\r\n            background: var(--profile-color-soft);\r\n            border: 1px solid var(--profile-color-mid);\r\n        }\r\n\r\n        #shl-roue-wp .profile-funding-row-label{\r\n            display: block;\r\n            margin-bottom: 0.22rem;\r\n            color: var(--profile-color);\r\n            font-size: 0.65rem;\r\n            font-weight: 850;\r\n            letter-spacing: 0.08em;\r\n            line-height: 1.2;\r\n            text-transform: uppercase;\r\n        }\r\n\r\n        #shl-roue-wp .profile-funding-row-value{\r\n            display: block;\r\n            color: #1f2d3d;\r\n            font-size: 0.98rem;\r\n            font-weight: 800;\r\n            line-height: 1.3;\r\n            overflow-wrap: break-word;\r\n            word-break: keep-all;\r\n        }\r\n\r\n        \/* D\u00e9tail des montants par ann\u00e9e : libell\u00e9 \u00e0 gauche, montant \u00e0 droite. *\/\r\n        #shl-roue-wp .pf-amounts{\r\n            display: flex;\r\n            flex-direction: column;\r\n            gap: 0.28rem;\r\n        }\r\n        #shl-roue-wp .pf-line{\r\n            display: flex;\r\n            align-items: baseline;\r\n            justify-content: space-between;\r\n            gap: 0.75rem;\r\n        }\r\n        #shl-roue-wp .pf-k{\r\n            color: #5b6b7d;\r\n            font-size: 0.74rem;\r\n            font-weight: 700;\r\n        }\r\n        #shl-roue-wp .pf-v{\r\n            color: #1f2d3d;\r\n            font-size: 0.98rem;\r\n            font-weight: 800;\r\n            font-variant-numeric: tabular-nums;\r\n            white-space: nowrap;\r\n        }\r\n\r\n        #shl-roue-wp .profile-contact-list{\r\n            display: flex;\r\n            flex-direction: column;\r\n            gap: 0.45rem;\r\n        }\r\n\r\n        #shl-roue-wp .profile-contact-row{\r\n            display: flex;\r\n            align-items: center;\r\n            gap: 0.7rem;\r\n            padding: 0.68rem 0.85rem;\r\n            border-radius: 12px;\r\n            background: #ffffff;\r\n            border: 1px solid #e6ebf1;\r\n            color: #1f2d3d;\r\n            font-size: 0.93rem;\r\n            font-weight: 600;\r\n            text-decoration: none;\r\n            line-height: 1.35;\r\n            min-width: 0;\r\n            transition: border-color 0.18s ease, background 0.18s ease, transform 0.15s ease, box-shadow 0.2s ease;\r\n        }\r\n        #shl-roue-wp .profile-contact-row:hover{\r\n            border-color: var(--profile-color-mid);\r\n            background: var(--profile-color-soft);\r\n            transform: translateY(-1px);\r\n            box-shadow: 0 4px 12px rgba(36, 71, 109, 0.08);\r\n        }\r\n        #shl-roue-wp .profile-contact-row--static, #shl-roue-wp .profile-contact-row--static:hover{\r\n            cursor: default;\r\n            transform: none;\r\n            box-shadow: none;\r\n            background: #ffffff;\r\n            border-color: #e6ebf1;\r\n        }\r\n\r\n        #shl-roue-wp .profile-contact-icon{\r\n            flex: 0 0 auto;\r\n            width: 34px;\r\n            height: 34px;\r\n            border-radius: 10px;\r\n            background: var(--profile-color);\r\n            color: #ffffff;\r\n            display: inline-flex;\r\n            align-items: center;\r\n            justify-content: center;\r\n        }\r\n        #shl-roue-wp .profile-contact-icon svg{ display: block; }\r\n\r\n        #shl-roue-wp .profile-contact-body{\r\n            display: flex;\r\n            flex-direction: column;\r\n            gap: 0.1rem;\r\n            min-width: 0;\r\n            flex: 1 1 auto;\r\n        }\r\n\r\n        #shl-roue-wp .profile-contact-label{\r\n            font-size: 0.64rem;\r\n            font-weight: 800;\r\n            letter-spacing: 0.08em;\r\n            text-transform: uppercase;\r\n            color: #94a3b8;\r\n        }\r\n\r\n        #shl-roue-wp .profile-contact-value{\r\n            color: #1f2d3d;\r\n            font-weight: 700;\r\n            font-size: 0.92rem;\r\n            overflow-wrap: anywhere;\r\n            line-height: 1.32;\r\n        }\r\n\r\n        #shl-roue-wp .profile-contact-arrow{\r\n            flex: 0 0 auto;\r\n            color: #cbd5e1;\r\n            transition: color 0.18s ease, transform 0.18s ease;\r\n        }\r\n        #shl-roue-wp .profile-contact-row:hover .profile-contact-arrow{\r\n            color: var(--profile-color);\r\n            transform: translateX(2px);\r\n        }\r\n\r\n        #shl-roue-wp .profile-project-links{\r\n            display: flex;\r\n            flex-direction: column;\r\n            align-items: flex-start;\r\n            gap: 0;\r\n        }\r\n        \/* Fiches Erasmus+ : m\u00eame style compact que la carte partenaire *\/\r\n        #shl-roue-wp .profile-erasmus-link{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 0.25rem;\r\n            font-size: 0.78rem;\r\n            font-weight: 700;\r\n            color: var(--profile-color);\r\n            text-decoration: none;\r\n            padding-top: 0.4rem;\r\n            margin-top: 0.4rem;\r\n            border-top: 1px dashed rgba(19, 36, 63, 0.16);\r\n        }\r\n        #shl-roue-wp .profile-project-links .profile-erasmus-link:first-child{\r\n            padding-top: 0;\r\n            margin-top: 0;\r\n            border-top: none;\r\n        }\r\n        #shl-roue-wp .profile-erasmus-link:hover{ text-decoration: underline; }\r\n\r\n        \/* Vid\u00e9o de pr\u00e9sentation : on affiche la miniature YouTube (fa\u00e7ade) et\r\n           l'iframe n'est charg\u00e9e qu'au clic, pour ne pas alourdir la fiche ni\r\n           d\u00e9poser de cookies YouTube tant que le visiteur ne lance pas la vid\u00e9o. *\/\r\n        #shl-roue-wp .profile-video{\r\n            --profile-video-ratio: 56.25%;\r\n            position: relative;\r\n            display: block;\r\n            width: 100%;\r\n            max-width: var(--profile-video-max, none);\r\n            margin: 0;\r\n            padding: 0;\r\n            border: 1px solid rgba(19, 36, 63, 0.12);\r\n            border-radius: 14px;\r\n            overflow: hidden;\r\n            background: #0f172a;\r\n            cursor: pointer;\r\n            box-shadow: 0 8px 22px rgba(15, 23, 42, 0.14);\r\n            transition: transform 0.18s ease, box-shadow 0.18s ease;\r\n        }\r\n        #shl-roue-wp .profile-video::before{\r\n            content: \"\";\r\n            display: block;\r\n            padding-top: var(--profile-video-ratio);\r\n        }\r\n        #shl-roue-wp .profile-video:hover{\r\n            transform: translateY(-2px);\r\n            box-shadow: 0 12px 26px rgba(15, 23, 42, 0.2);\r\n        }\r\n        #shl-roue-wp .profile-video.is-playing,\r\n        #shl-roue-wp .profile-video.is-playing:hover{\r\n            transform: none;\r\n            cursor: default;\r\n        }\r\n        #shl-roue-wp .profile-video:focus-visible{\r\n            outline: 2px solid var(--profile-color);\r\n            outline-offset: 2px;\r\n        }\r\n        #shl-roue-wp .profile-video-thumb{\r\n            position: absolute;\r\n            inset: 0;\r\n            width: 100%;\r\n            height: 100%;\r\n            object-fit: cover;\r\n            display: block;\r\n            border: none;\r\n        }\r\n        #shl-roue-wp .profile-video-veil{\r\n            position: absolute;\r\n            inset: 0;\r\n            background: linear-gradient(180deg, rgba(15, 23, 42, 0) 40%, rgba(15, 23, 42, 0.72) 100%);\r\n        }\r\n        #shl-roue-wp .profile-video-play{\r\n            position: absolute;\r\n            top: 50%;\r\n            left: 50%;\r\n            transform: translate(-50%, -50%);\r\n            width: 58px;\r\n            height: 58px;\r\n            border-radius: 50%;\r\n            display: flex;\r\n            align-items: center;\r\n            justify-content: center;\r\n            background: rgba(255, 255, 255, 0.94);\r\n            color: var(--profile-color);\r\n            box-shadow: 0 6px 18px rgba(15, 23, 42, 0.35);\r\n            transition: transform 0.18s ease;\r\n        }\r\n        #shl-roue-wp .profile-video:hover .profile-video-play{\r\n            transform: translate(-50%, -50%) scale(1.07);\r\n        }\r\n        #shl-roue-wp .profile-video-caption{\r\n            position: absolute;\r\n            left: 0;\r\n            right: 0;\r\n            bottom: 0;\r\n            padding: 0.7rem 0.85rem;\r\n            color: #fff;\r\n            font-size: 0.78rem;\r\n            font-weight: 800;\r\n            letter-spacing: 0.02em;\r\n            text-align: left;\r\n        }\r\n        #shl-roue-wp .profile-video-frame{\r\n            position: absolute;\r\n            inset: 0;\r\n            width: 100%;\r\n            height: 100%;\r\n            border: none;\r\n        }\r\n        #shl-roue-wp .profile-video-link{\r\n            display: inline-flex;\r\n            align-items: center;\r\n            gap: 0.25rem;\r\n            margin-top: 0.5rem;\r\n            font-size: 0.78rem;\r\n            font-weight: 700;\r\n            color: var(--profile-color);\r\n            text-decoration: none;\r\n        }\r\n        #shl-roue-wp .profile-video-link:hover{ text-decoration: underline; }\r\n\r\n        #shl-roue-wp .profile-contact-meta{\r\n            margin: 0.9rem 0 0;\r\n            color: #64748b;\r\n            font-size: 0.85rem;\r\n            line-height: 1.55;\r\n        }\r\n        #shl-roue-wp .profile-contact-meta strong{\r\n            color: #324456;\r\n            font-weight: 800;\r\n        }\r\n\r\n        #shl-roue-wp .profile-accordion{\r\n            border: 1px solid #e2e8f0;\r\n            border-radius: 12px;\r\n            overflow: hidden;\r\n            margin: 0.4rem 0 0.5rem;\r\n        }\r\n        #shl-roue-wp .profile-accordion[open]{ border-color: var(--profile-color-mid); }\r\n        #shl-roue-wp .profile-accordion-toggle{\r\n            list-style: none;\r\n            cursor: pointer;\r\n            padding: 0.85rem 1.05rem;\r\n            display: flex;\r\n            align-items: center;\r\n            justify-content: space-between;\r\n            font-size: 0.74rem;\r\n            font-weight: 800;\r\n            text-transform: uppercase;\r\n            letter-spacing: 0.08em;\r\n            color: #475569;\r\n            background: #f8fafc;\r\n            transition: background 0.2s ease;\r\n        }\r\n        #shl-roue-wp .profile-accordion-toggle::-webkit-details-marker{ display: none; }\r\n        #shl-roue-wp .profile-accordion-toggle::after{\r\n            content: \"\u25be\";\r\n            transition: transform 0.25s ease;\r\n            color: var(--profile-color);\r\n            font-size: 0.95rem;\r\n        }\r\n        #shl-roue-wp .profile-accordion[open] .profile-accordion-toggle::after{ transform: rotate(180deg); }\r\n        #shl-roue-wp .profile-accordion-toggle:hover{ background: #eef2f7; }\r\n        #shl-roue-wp .profile-accordion-content{ padding: 0.95rem 1.05rem 1.05rem; }\r\n        #shl-roue-wp .profile-accordion-content .asso-detail-grid{\r\n            margin: 0;\r\n            padding: 0;\r\n            border-bottom: none;\r\n            gap: 0.65rem;\r\n        }\r\n\r\n        #shl-roue-wp .tooltip{\r\n            position: absolute;\r\n            pointer-events: none;\r\n            background: rgba(15, 23, 42, 0.9);\r\n            color: white;\r\n            padding: 8px 12px;\r\n            border-radius: 6px;\r\n            font-size: 0.85rem;\r\n            font-weight: 600;\r\n            opacity: 0;\r\n            transition: opacity 0.2s;\r\n            box-shadow: 0 4px 10px rgba(0,0,0,0.2);\r\n            z-index: 100;\r\n            max-width: 250px;\r\n            text-align: center;\r\n        }\r\n\r\n        @media (prefers-reduced-motion: reduce) {\r\n            #shl-roue-wp *{\r\n                animation-duration: 0.01ms !important;\r\n                animation-iteration-count: 1 !important;\r\n                transition-duration: 0.01ms !important;\r\n                scroll-behavior: auto !important;\r\n            }\r\n        }\r\n\r\n\r\n        @media (prefers-reduced-motion: reduce) {\r\n            #shl-roue-wp path.arc, #shl-roue-wp .association-dot, #shl-roue-wp text.label, #shl-roue-wp text.center-text, #shl-roue-wp .sidebar, #shl-roue-wp .close-btn, #shl-roue-wp .tooltip, #shl-roue-wp .slider, #shl-roue-wp .slider:before{\r\n                transition: none !important;\r\n                animation: none !important;\r\n            }\r\n        }\r\n\r\n            @media (max-width: 900px), (pointer: coarse) and (max-width: 1180px) {\r\n            #shl-roue-wp .header{\r\n                top: 0.65rem;\r\n                left: 0.65rem;\r\n                right: auto;\r\n            }\r\n            body.sidebar-open #shl-roue-wp .header{\r\n                right: auto;\r\n            }\r\n            #shl-roue-wp .map-title{\r\n                max-width: min(330px, calc(100vw - 1.3rem));\r\n                margin-bottom: 0.8rem;\r\n            }\r\n            #shl-roue-wp .map-title-eyebrow{\r\n                font-size: 0.64rem;\r\n            }\r\n            #shl-roue-wp .map-title-main{\r\n                font-size: clamp(1.05rem, 5.2vw, 1.55rem);\r\n            }\r\n            #shl-roue-wp .toolbar{\r\n                width: 100%;\r\n                flex-direction: column;\r\n                align-items: stretch;\r\n                gap: 0.4rem;\r\n            }\r\n            #shl-roue-wp .toggle-group{\r\n                width: 100%;\r\n                max-width: 460px;\r\n                flex-direction: row;\r\n                border-radius: 18px;\r\n                gap: 0.35rem;\r\n                padding: 0.32rem;\r\n            }\r\n            #shl-roue-wp .zone-select-group{\r\n                width: fit-content;\r\n                max-width: calc(100vw - 1.3rem);\r\n                min-width: 0;\r\n                align-self: flex-start;\r\n            }\r\n            #shl-roue-wp .filter-group{\r\n                width: 100%;\r\n                max-width: 460px;\r\n                min-width: 0;\r\n                padding: 0.4rem 0.5rem 0.5rem;\r\n            }\r\n            #shl-roue-wp .filter-btn{\r\n                font-size: 0.74rem;\r\n                padding: 0.5rem 0.6rem;\r\n            }\r\n            #shl-roue-wp .toggle-btn{\r\n                flex: 1 1 0;\r\n                min-width: 0;\r\n                padding: 0.68rem 0.58rem;\r\n                font-size: 0.78rem;\r\n                white-space: normal;\r\n                line-height: 1.1;\r\n            }\r\n            #shl-roue-wp .association-label{\r\n                display: none;\r\n            }\r\n            #shl-roue-wp .association-dot{\r\n                stroke-width: 3.2px;\r\n                filter: drop-shadow(0 3px 7px rgba(15, 23, 42, 0.34));\r\n            }\r\n            #shl-roue-wp .association-leader{\r\n                stroke-width: 1.9px;\r\n                opacity: 0.86;\r\n            }\r\n            #shl-roue-wp path.funding-arc{\r\n                stroke-width: 2px;\r\n            }\r\n            #shl-roue-wp .sidebar{\r\n                top: auto;\r\n                bottom: 0;\r\n                width: 100%;\r\n                height: min(58vh, 430px);\r\n                border-left: none;\r\n                border-top: 1px solid var(--border-color);\r\n                border-radius: 18px 18px 0 0;\r\n                transform: translateY(0);\r\n            }\r\n            #shl-roue-wp .sidebar.hidden{\r\n                transform: translateY(110%);\r\n                opacity: 0;\r\n                pointer-events: none;\r\n            }\r\n            #shl-roue-wp .sidebar-content{\r\n                padding: 3.2rem 1.25rem 1.35rem;\r\n            }\r\n            #shl-roue-wp .asso-header-block{\r\n                padding-right: 2.4rem;\r\n                margin-bottom: 0.95rem;\r\n            }\r\n            #shl-roue-wp .asso-title{\r\n                font-size: 1.55rem;\r\n            }\r\n            #shl-roue-wp .summary-row{\r\n                grid-template-columns: 1fr;\r\n                gap: 0.22rem;\r\n            }\r\n            #shl-roue-wp .asso-desc{\r\n                font-size: 0.95rem;\r\n                margin: 0;\r\n                padding-bottom: 0;\r\n            }\r\n            #shl-roue-wp .detail-row{\r\n                grid-template-columns: 1fr;\r\n                gap: 0.25rem;\r\n            }\r\n            #shl-roue-wp .profile-hero{\r\n                margin: -3.2rem -1.25rem 1.25rem;\r\n                padding: 3.05rem 1.25rem 1.4rem;\r\n            }\r\n            #shl-roue-wp .profile-hero-title{\r\n                font-size: 1.5rem;\r\n            }\r\n            #shl-roue-wp .profile-pills{\r\n                grid-template-columns: 1fr 1fr;\r\n            }\r\n            #shl-roue-wp .profile-description{\r\n                font-size: 0.96rem;\r\n            }\r\n            #shl-roue-wp .close-btn{\r\n                top: 1rem;\r\n                right: 1rem;\r\n            }\r\n            #shl-roue-wp .tooltip{\r\n                display: none;\r\n            }\r\n        }\r\n\r\n        @media (max-width: 520px) {\r\n            #shl-roue-wp .toggle-btn{\r\n                font-size: 0.72rem;\r\n                padding: 0.6rem 0.42rem;\r\n            }\r\n            #shl-roue-wp text.label{\r\n                stroke-width: 1px;\r\n            }\r\n            #shl-roue-wp text.label.item-label{\r\n                stroke-width: 1.15px;\r\n            }\r\n            #shl-roue-wp .sidebar{\r\n                height: 64vh;\r\n            }\r\n        }\r\n\r\n\r\n\/* Bouclier anti-th\u00e8me WordPress pour les textes SVG *\/\r\n#shl-roue-wp svg text, \r\n#shl-roue-wp svg tspan, \r\n#shl-roue-wp svg textPath {\r\n    font-family: 'Manrope', sans-serif !important;\r\n    text-shadow: none !important;\r\n    text-transform: none !important;\r\n    line-height: normal !important;\r\n}\r\n#shl-roue-wp text.label,\r\n#shl-roue-wp text.domain-title-text,\r\n#shl-roue-wp .sub-bubble-count {\r\n    fill: #ffffff !important;\r\n    color: #ffffff !important;\r\n}\r\n#shl-roue-wp text.center-text, \r\n#shl-roue-wp text.center-back-icon, \r\n#shl-roue-wp text.center-back-label {\r\n    fill: #24476d !important;\r\n    color: #24476d !important;\r\n}\r\n\r\n\/* Menu zone compact - remplace le select natif plein \u00e9cran *\/\r\n#shl-roue-wp .zone-select {\r\n    display: none !important;\r\n}\r\n\r\n#shl-roue-wp .zone-select-group {\r\n    position: relative !important;\r\n    overflow: visible !important;\r\n    z-index: 50 !important;\r\n}\r\n\r\n#shl-roue-wp .zone-picker-button {\r\n    min-width: 130px;\r\n    max-width: 300px;\r\n    border: 1px solid rgba(36, 71, 109, 0.16);\r\n    border-radius: 999px;\r\n    padding: 0.55rem 0.75rem 0.55rem 0.8rem;\r\n    background: rgba(255, 255, 255, 0.86);\r\n    color: var(--text-color);\r\n    font: inherit;\r\n    font-size: 0.82rem;\r\n    font-weight: 750;\r\n    cursor: pointer;\r\n    outline: none;\r\n    display: inline-flex;\r\n    align-items: center;\r\n    justify-content: space-between;\r\n    gap: 0.55rem;\r\n    box-shadow: none;\r\n    text-align: left;\r\n}\r\n\r\n#shl-roue-wp .zone-picker-button:hover, \r\n#shl-roue-wp .zone-picker-button:focus {\r\n    background: white;\r\n    border-color: rgba(36, 71, 109, 0.34);\r\n    box-shadow: 0 8px 18px rgba(36, 71, 109, 0.1);\r\n}\r\n\r\n#shl-roue-wp #zone-picker-current {\r\n    display: block;\r\n    max-width: 225px;\r\n    overflow: hidden;\r\n    text-overflow: ellipsis;\r\n    white-space: nowrap;\r\n}\r\n\r\n#shl-roue-wp .zone-picker-chevron {\r\n    flex: 0 0 auto;\r\n    font-size: 0.78rem;\r\n    opacity: 0.72;\r\n}\r\n\r\n#shl-roue-wp .zone-picker-options {\r\n    position: absolute;\r\n    left: 0.55rem;\r\n    top: calc(100% + 0.4rem);\r\n    width: min(300px, calc(100vw - 1.3rem));\r\n    max-height: min(280px, 52vh);\r\n    overflow-y: auto;\r\n    background: rgba(255, 255, 255, 0.98);\r\n    border: 1px solid rgba(231, 223, 217, 0.95);\r\n    border-radius: 16px;\r\n    box-shadow: 0 18px 40px rgba(36, 71, 109, 0.16);\r\n    padding: 0.35rem;\r\n    z-index: 2147483647 !important;\r\n    backdrop-filter: blur(10px);\r\n    -webkit-backdrop-filter: blur(10px);\r\n}\r\n\r\n#shl-roue-wp .zone-picker-option {\r\n    width: 100%;\r\n    border: none;\r\n    background: transparent;\r\n    color: var(--text-color);\r\n    font: inherit;\r\n    font-size: 0.82rem;\r\n    font-weight: 700;\r\n    text-align: left;\r\n    padding: 0.62rem 0.75rem;\r\n    border-radius: 12px;\r\n    cursor: pointer;\r\n    display: block;\r\n    white-space: normal;\r\n    line-height: 1.2;\r\n}\r\n\r\n#shl-roue-wp .zone-picker-option:hover, \r\n#shl-roue-wp .zone-picker-option.active {\r\n    background: rgba(36, 71, 109, 0.08);\r\n    color: var(--text-color);\r\n}\r\n\r\n#shl-roue-wp .header {\r\n    z-index: 2147483000 !important;\r\n    overflow: visible !important;\r\n    isolation: isolate !important;\r\n    pointer-events: none !important;\r\n    right: auto !important;\r\n    width: fit-content !important;\r\n    max-width: calc(100vw - 2.2rem) !important;\r\n}\r\n\r\n#shl-roue-wp .toolbar {\r\n    overflow: visible !important;\r\n    isolation: isolate !important;\r\n    pointer-events: auto !important;\r\n}\r\n\r\n\/* Bouclier anti-th\u00e8me : .sidebar, .main-content, .toolbar sont des noms de classe\r\n   tr\u00e8s courants dans les th\u00e8mes WordPress. Le th\u00e8me peut leur injecter un\r\n   padding\/margin parasite \u2014 c'est ce qui provoquait la bande blanche en haut du\r\n   panneau d'association. On force donc une remise \u00e0 z\u00e9ro. La r\u00e8gle de padding\r\n   interne reste port\u00e9e par .sidebar-content. *\/\r\n#shl-roue-wp .sidebar,\r\n#shl-roue-wp .main-content,\r\n#shl-roue-wp .toolbar {\r\n    margin: 0 !important;\r\n    padding: 0 !important;\r\n}\r\n#shl-roue-wp .sidebar-content {\r\n    margin: 0 !important;\r\n}\r\n\r\n\/* Sidebar ouverte : on garde TOUS les filtres accessibles (zone, Projets\/Financements,\r\n   cat\u00e9gories de financement). Seul le titre se met en retrait via .map-title (cf. r\u00e8gle d\u00e9di\u00e9e). *\/\r\nbody.sidebar-open #shl-roue-wp .toolbar {\r\n    opacity: 1 !important;\r\n    transform: none !important;\r\n    pointer-events: auto !important;\r\n}\r\n\r\n#shl-roue-wp .toggle-group, \r\n#shl-roue-wp .filter-group {\r\n    position: relative !important;\r\n    z-index: 12 !important;\r\n    pointer-events: auto !important;\r\n}\r\n\r\n#shl-roue-wp .zone-select-group {\r\n    position: relative !important;\r\n    z-index: 2147483600 !important;\r\n    pointer-events: auto !important;\r\n}\r\n\r\n@media (max-width: 900px), (pointer: coarse) and (max-width: 1180px) {\r\n    #shl-roue-wp .zone-picker-options {\r\n        left: 0;\r\n        top: calc(100% + 0.35rem);\r\n        width: min(360px, calc(100vw - 1.3rem));\r\n        max-height: 240px;\r\n    }\r\n}\r\n\r\n<\/style>\r\n\r\n<div id=\"shl-roue-wp\">\r\n<div id=\"app-container\">\r\n<header class=\"header\">\r\n<div class=\"toolbar\">\r\n<div class=\"map-title\" aria-label=\"Cartographie des projets incub\u00e9s\">\r\n<span class=\"map-title-eyebrow\">Cartographie<\/span>\r\n<strong class=\"map-title-main\">Cartographie des projets incub\u00e9s<\/strong>\r\n<\/div>\r\n<div aria-label=\"Filtrer par zone\" class=\"zone-select-group\" id=\"zone-filters\">\r\n<label class=\"zone-select-label\" for=\"zone-select\">Zone<\/label>\r\n<button aria-expanded=\"false\" class=\"zone-picker-button\" id=\"zone-picker-button\" type=\"button\">\r\n<span id=\"zone-picker-current\">Toutes les zones<\/span>\r\n<span class=\"zone-picker-chevron\">\u25be<\/span>\r\n<\/button>\r\n<div class=\"zone-picker-options\" hidden=\"\" id=\"zone-picker-options\"><\/div>\r\n<select aria-hidden=\"true\" class=\"zone-select\" id=\"zone-select\" tabindex=\"-1\"><\/select>\r\n<\/div>\r\n<div class=\"toggle-group\" id=\"funding-toggle\">\r\n<button aria-pressed=\"true\" class=\"toggle-btn active\" data-mode=\"none\" type=\"button\">Projets<\/button>\r\n<button aria-pressed=\"false\" class=\"toggle-btn\" data-mode=\"category\" type=\"button\">Financements<\/button>\r\n<\/div>\r\n<div class=\"filter-group\" hidden=\"\" id=\"funding-filters\">\r\n<span class=\"filter-label\">Cat\u00e9gories de financement<\/span>\r\n<button aria-pressed=\"false\" class=\"filter-btn\" data-group=\"Programmes sectoriels\" style=\"--filter-color:#cc4f70;\" type=\"button\">\r\n<span class=\"filter-dot\"><\/span>Programmes sectoriels\r\n<\/button>\r\n<button aria-pressed=\"false\" class=\"filter-btn\" data-group=\"Coop\u00e9ration territoriale\" style=\"--filter-color:#d4a14c;\" type=\"button\">\r\n<span class=\"filter-dot\"><\/span>Coop\u00e9ration territoriale\r\n<\/button>\r\n<button aria-pressed=\"false\" class=\"filter-btn\" data-group=\"Fonds structurels\" style=\"--filter-color:#1c95a6;\" type=\"button\">\r\n<span class=\"filter-dot\"><\/span>Fonds structurels\r\n<\/button>\r\n<\/div>\r\n<button class=\"assos-switch-group\" id=\"assos-toggle\" type=\"button\" role=\"switch\" aria-checked=\"false\">\r\n<span class=\"assos-switch-label\">Associations<\/span>\r\n<span class=\"assos-switch\" aria-hidden=\"true\"><span class=\"assos-switch-thumb\"><\/span><\/span>\r\n<\/button>\r\n<\/div>\r\n<\/header>\r\n<main class=\"main-content\">\r\n<div id=\"chart-container\"><\/div>\r\n<aside class=\"funding-legend\" id=\"funding-legend\" aria-label=\"Programmes europ\u00e9ens\"><\/aside>\r\n<button class=\"detail-prompt\" id=\"detail-prompt\" type=\"button\" aria-live=\"polite\" aria-hidden=\"true\">\r\n<span class=\"detail-prompt-icon\" id=\"detail-prompt-icon\">\u2197<\/span>\r\n<span class=\"detail-prompt-text\">\r\n<span class=\"detail-prompt-eyebrow\" id=\"detail-prompt-eyebrow\">Cat\u00e9gorie s\u00e9lectionn\u00e9e<\/span>\r\n<span class=\"detail-prompt-title\" id=\"detail-prompt-title\">\u2014<\/span>\r\n<\/span>\r\n<span class=\"detail-prompt-cta\">Voir \u2192<\/span>\r\n<span class=\"detail-prompt-close\" id=\"detail-prompt-close\" role=\"button\" aria-label=\"Fermer\">\u00d7<\/span>\r\n<\/button>\r\n<aside class=\"sidebar hidden\" id=\"sidebar\">\r\n<button class=\"sidebar-back-btn\" id=\"sidebar-back-btn\" type=\"button\" hidden aria-label=\"Retour\">\r\n<span class=\"sidebar-back-icon\" aria-hidden=\"true\">\u2190<\/span>\r\n<span class=\"sidebar-back-label\">Retour<\/span>\r\n<\/button>\r\n<button class=\"close-btn\" id=\"close-sidebar\" type=\"button\" aria-label=\"Fermer\">\u00d7<\/button>\r\n<div class=\"sidebar-content\" id=\"sidebar-content\"><\/div>\r\n<\/aside>\r\n<div class=\"tooltip\" id=\"tooltip\"><\/div>\r\n<\/main>\r\n<\/div>\r\n<\/div>\r\n\r\n<script>\r\n(function () {\r\n    if (window.__shlRoueWPCodeLoaded) return;\r\n    window.__shlRoueWPCodeLoaded = true;\r\n\r\n    let __shlRoueAttempts = 0;\r\n    function __shlRoueBoot() {\r\n        const container = document.querySelector('#shl-roue-wp #chart-container');\r\n        if (typeof d3 !== 'undefined' && container && container.clientWidth > 30 && container.clientHeight > 30) {\r\n            __shlRoueInit();\r\n            return;\r\n        }\r\n        if (__shlRoueAttempts < 180) {\r\n            __shlRoueAttempts++;\r\n            setTimeout(__shlRoueBoot, 50);\r\n            return;\r\n        }\r\n        console.error('SHL Roue : D3 non charg\u00e9 ou conteneur non mesurable.');\r\n    }\r\n\r\n    if (document.readyState === 'loading') {\r\n        document.addEventListener('DOMContentLoaded', __shlRoueBoot);\r\n    } else {\r\n        __shlRoueBoot();\r\n    }\r\n\r\n    function __shlRoueInit() {\r\n\r\n        \/\/ 1. DATA\r\n        const D = [\r\n            {\r\n                titleLines: [\"Coh\u00e9sion sociale\", \"& citoyennet\u00e9\"],\r\n                name: \"Coh\u00e9sion sociale & citoyennet\u00e9\", color: \"#2b6f9f\", light: \"#79b3d5\", lightAlt: \"#5291ba\", accent: \"#d6eaf4\", accentAlt: \"#a7cee4\", items: [\r\n                    { name: \"Lutte contre les discriminations\", subs: [\"campagnes de sensibilisation\", \"formations \u00e0 l\u2019\u00e9galit\u00e9\", \"programmes d\u2019inclusion\"] },\r\n                    { name: \"Participation citoyenne\", subs: [\"d\u00e9bats citoyens\", \"universit\u00e9s populaires\", \"budgets participatifs\"] },\r\n                    { name: \"Inclusion sociale\", subs: [\"projets interg\u00e9n\u00e9rationnels\", \"accompagnement des publics vuln\u00e9rables\", \"insertion sociale\"] },\r\n                    { name: \"Acc\u00e8s aux droits\", subs: [\"m\u00e9diation sociale\", \"permanences juridiques\", \"information des habitants\"] }\r\n                ]\r\n            },\r\n            {\r\n                titleLines: [\"Jeunesse, \u00e9ducation\", \"et comp\u00e9tences\"],\r\n                name: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", color: \"#c49432\", light: \"#d9bc63\", lightAlt: \"#cea84a\", accent: \"#f4e7bd\", accentAlt: \"#e6d190\", items: [\r\n                    { name: \"Jeunesse\", subs: [\"\u00e9changes de jeunes\", \"volontariat europ\u00e9en\", \"projets sportifs inclusifs\"] },\r\n                    { name: \"\u00c9ducation\", subs: [\"projets \u00e9ducatifs innovants\", \"lutte contre d\u00e9crochage scolaire\", \"projets p\u00e9dagogiques europ\u00e9ens\"] },\r\n                    { name: \"Mobilit\u00e9\", subs: [\"mobilit\u00e9s europ\u00e9ennes\", \"visites d\u2019\u00e9tude\", \"\u00e9changes professionnels\"] },\r\n                    { name: \"Insertion\", subs: [\"formation professionnelle\", \"entrepreneuriat des jeunes\", \"accompagnement vers l\u2019emploi\"] }\r\n                ]\r\n            },\r\n            {\r\n                titleLines: [\"Culture & cr\u00e9ativit\u00e9\", \"territoriale\"],\r\n                name: \"Culture et cr\u00e9ativit\u00e9 territoriale\", color: \"#74518d\", light: \"#b197c8\", lightAlt: \"#9274aa\", accent: \"#e6daee\", accentAlt: \"#cbb8db\", items: [\r\n                    { name: \"M\u00e9diation culturelle\", subs: [\"ateliers artistiques\", \"projets culture-\u00e9ducation\", \"m\u00e9diation aupr\u00e8s des habitants\"] },\r\n                    { name: \"Valorisation des territoires\", subs: [\"patrimoine local\", \"tourisme culturel\", \"projets d\u2019identit\u00e9 territoriale\"] },\r\n                    { name: \"Projets artistiques\", subs: [\"r\u00e9sidences d\u2019artistes\", \"cr\u00e9ations collaboratives\", \"projets art et quartiers\"] },\r\n                    { name: \"Culture\", subs: [\"festivals culturels\", \"\u00e9v\u00e9nements artistiques\", \"projets culturels participatifs\"] }\r\n                ]\r\n            },\r\n            {\r\n                titleLines: [\"Transition \u00e9cologique\", \"et innovation\", \"sociale\"],\r\n                name: \"Transition \u00e9cologique et innovation sociale\", color: \"#2f8d69\", light: \"#83ccb3\", lightAlt: \"#59ac8e\", accent: \"#d2ebe2\", accentAlt: \"#aadbca\", items: [\r\n                    { name: \"Transition \u00e9cologique\", subs: [\"projets climat\", \"sensibilisation environnementale\", \"initiatives z\u00e9ro d\u00e9chet\"] },\r\n                    { name: \"Innovation sociale\", subs: [\"exp\u00e9rimentations sociales\", \"tiers-lieux\", \"nouveaux services locaux\"] },\r\n                    { name: \"Projets num\u00e9riques\", subs: [\"inclusion num\u00e9rique\", \"plateformes collaboratives\", \"projets num\u00e9riques citoyens\"] },\r\n                    { name: \"Nouveaux mod\u00e8les \u00e9conomiques\", subs: [\"\u00e9conomie sociale et solidaire\", \"entrepreneuriat social\", \"circuits courts locaux\"] }\r\n                ]\r\n            }\r\n        ];\r\n\r\n        const hierarchyData = {\r\n            name: \"Tous les domaines\",\r\n            color: \"#ffffff\",\r\n            children: D.map(domain => {\r\n                let subRunningIndex = 0;\r\n                return {\r\n                    name: domain.name,\r\n                    titleLines: domain.titleLines,\r\n                    color: domain.color,\r\n                    light: domain.light,\r\n                    accent: domain.accent,\r\n                    children: domain.items.map((item, itemIndex) => ({\r\n                        name: item.name,\r\n                        color: itemIndex % 2 === 0 ? domain.lightAlt : domain.light,\r\n                        children: item.subs.map(sub => ({\r\n                            name: sub,\r\n                            color: subRunningIndex++ % 2 === 0 ? domain.accentAlt : domain.accent,\r\n                            value: 1\r\n                        }))\r\n                    }))\r\n                };\r\n            })\r\n        };\r\n\r\n        const fallbackAssociations = [\r\n            { id: 1, name: \"ACCO\", domain: \"Transition \u00e9cologique et innovation sociale\", item: \"Transition \u00e9cologique\", sub: \"projets climat\", desc: \"Structure rep\u00e9r\u00e9e dans le PDF SHL sur l'axe Transition \u00e9cologique, rattach\u00e9e au sous-th\u00e8me Projets climat.\" },\r\n            { id: 2, name: \"Les Cols Verts Provence\", domain: \"Transition \u00e9cologique et innovation sociale\", item: \"Transition \u00e9cologique\", sub: \"projets climat\", desc: \"Association positionn\u00e9e sur les projets climat dans la cartographie SHL.\" },\r\n            { id: 3, name: \"Cit\u00e9 plan\u00e9taire\", domain: \"Transition \u00e9cologique et innovation sociale\", item: \"Transition \u00e9cologique\", sub: \"sensibilisation environnementale\", desc: \"Structure associ\u00e9e aux d\u00e9marches de sensibilisation environnementale.\" },\r\n            { id: 4, name: \"Arch\u00e9ologie Port-de-Bouc\", domain: \"Transition \u00e9cologique et innovation sociale\", item: \"Transition \u00e9cologique\", sub: \"sensibilisation environnementale\", desc: \"Structure cit\u00e9e dans le PDF sur le sous-th\u00e8me Sensibilisation environnementale.\" },\r\n            { id: 5, name: \"La Nu\u00e9e\", domain: \"Transition \u00e9cologique et innovation sociale\", item: \"Innovation sociale\", sub: \"exp\u00e9rimentations sociales\", desc: \"Structure positionn\u00e9e sur les exp\u00e9rimentations sociales.\" },\r\n            { id: 6, name: \"Effi-science\", domain: \"Transition \u00e9cologique et innovation sociale\", item: \"Projets num\u00e9riques\", sub: \"inclusion num\u00e9rique\", desc: \"Structure associ\u00e9e \u00e0 l'inclusion num\u00e9rique dans la cartographie.\" },\r\n            { id: 7, name: \"En Majuscule\", domain: \"Transition \u00e9cologique et innovation sociale\", item: \"Projets num\u00e9riques\", sub: \"plateformes collaboratives\", desc: \"Structure rattach\u00e9e aux projets num\u00e9riques citoyens dans le PDF.\" },\r\n            { id: 8, name: \"Code Social\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Lutte contre les discriminations\", sub: \"programmes d\u2019inclusion\", desc: \"Structure positionn\u00e9e sur les programmes d'inclusion.\" },\r\n            { id: 9, name: \"AMEL France\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Lutte contre les discriminations\", sub: \"programmes d\u2019inclusion\", desc: \"Association cit\u00e9e dans le PDF sur les programmes d'inclusion.\" },\r\n            { id: 10, name: \"Centre social Nelson Mandela\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Lutte contre les discriminations\", sub: \"programmes d\u2019inclusion\", desc: \"Structure accompagn\u00e9e par SHL, associ\u00e9e aux programmes d'inclusion.\" },\r\n            { id: 11, name: \"SoliMove\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Lutte contre les discriminations\", sub: \"programmes d\u2019inclusion\", desc: \"Structure cit\u00e9e dans l'axe Coh\u00e9sion sociale.\" },\r\n            { id: 12, name: \"Effi-science\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Participation citoyenne\", sub: \"budgets participatifs\", desc: \"Structure rattach\u00e9e aux d\u00e9marches de participation citoyenne.\" },\r\n            { id: 13, name: \"Terre Ludique\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Inclusion sociale\", sub: \"projets interg\u00e9n\u00e9rationnels\", desc: \"Association positionn\u00e9e sur les projets interg\u00e9n\u00e9rationnels.\" },\r\n            { id: 14, name: \"L'Atelier du Sud\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Inclusion sociale\", sub: \"projets interg\u00e9n\u00e9rationnels\", desc: \"Structure cit\u00e9e sur le sous-th\u00e8me Projets interg\u00e9n\u00e9rationnels.\" },\r\n            { id: 15, name: \"Association AFIRELIM\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Inclusion sociale\", sub: \"accompagnement des publics vuln\u00e9rables\", desc: \"Association rattach\u00e9e \u00e0 l'accompagnement des publics vuln\u00e9rables.\" },\r\n            { id: 16, name: \"R\u00e9gie de quartier de Pont-de-l'Arc\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Inclusion sociale\", sub: \"insertion sociale\", desc: \"Structure positionn\u00e9e sur l'insertion sociale.\" },\r\n            { id: 17, name: \"Ateliers de l'Arc\", domain: \"Coh\u00e9sion sociale & citoyennet\u00e9\", item: \"Inclusion sociale\", sub: \"insertion sociale\", desc: \"Structure cit\u00e9e dans le PDF sur le sous-th\u00e8me Insertion sociale.\" },\r\n            { id: 18, name: \"Act for Planet\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"Jeunesse\", sub: \"\u00e9changes de jeunes\", desc: \"Association positionn\u00e9e sur les \u00e9changes de jeunes.\" },\r\n            { id: 19, name: \"Substrat culturel et sportif de Port-de-Bouc\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"Jeunesse\", sub: \"\u00e9changes de jeunes\", desc: \"Structure cit\u00e9e dans le PDF sur les \u00e9changes de jeunes.\" },\r\n            { id: 20, name: \"A\u00efta Sport\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"Jeunesse\", sub: \"projets sportifs inclusifs\", desc: \"Association rattach\u00e9e aux projets sportifs inclusifs.\" },\r\n            { id: 21, name: \"Accueil Loisirs\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"Jeunesse\", sub: \"projets sportifs inclusifs\", desc: \"Structure positionn\u00e9e sur les projets sportifs inclusifs.\" },\r\n            { id: 22, name: \"Maison de cultures et de la jeunesse\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"\u00c9ducation\", sub: \"projets \u00e9ducatifs innovants\", desc: \"Structure rattach\u00e9e aux projets \u00e9ducatifs innovants.\" },\r\n            { id: 23, name: \"Association Cultures et Fronti\u00e8res Marseille\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"\u00c9ducation\", sub: \"projets p\u00e9dagogiques europ\u00e9ens\", desc: \"Association positionn\u00e9e sur les projets p\u00e9dagogiques europ\u00e9ens.\" },\r\n            { id: 24, name: \"Apprendre l'anglais\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"Mobilit\u00e9\", sub: \"mobilit\u00e9s europ\u00e9ennes\", desc: \"Structure associ\u00e9e aux mobilit\u00e9s europ\u00e9ennes.\" },\r\n            { id: 25, name: \"Centre social Nelson Mandela\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"Insertion\", sub: \"formation professionnelle\", desc: \"Structure cit\u00e9e sur le sous-th\u00e8me Formation professionnelle.\" },\r\n            { id: 26, name: \"Contact Club\", domain: \"Jeunesse, \u00e9ducation et comp\u00e9tences\", item: \"Insertion\", sub: \"formation professionnelle\", desc: \"Structure rattach\u00e9e aux formations professionnelles.\" },\r\n            { id: 27, name: \"Le Poulpe Savant\", domain: \"Culture et cr\u00e9ativit\u00e9 territoriale\", item: \"Culture\", sub: \"festivals culturels\", desc: \"Structure cit\u00e9e dans le PDF sur les festivals culturels.\" },\r\n            { id: 28, name: \"Les Points Sauvants - Th\u00e9\u00e2tre\", domain: \"Culture et cr\u00e9ativit\u00e9 territoriale\", item: \"Culture\", sub: \"\u00e9v\u00e9nements artistiques\", desc: \"Structure positionn\u00e9e sur les \u00e9v\u00e9nements artistiques.\" },\r\n            { id: 29, name: \"P\u00d8ST Collectif\", domain: \"Culture et cr\u00e9ativit\u00e9 territoriale\", item: \"Culture\", sub: \"\u00e9v\u00e9nements artistiques\", desc: \"Collectif cit\u00e9 dans la cartographie Culture.\" },\r\n            { id: 30, name: \"Po\u00efem\", domain: \"Culture et cr\u00e9ativit\u00e9 territoriale\", item: \"Culture\", sub: \"projets culturels participatifs\", desc: \"Structure rattach\u00e9e aux projets culturels participatifs.\" },\r\n            { id: 31, name: \"Pavillon Campus\", domain: \"Culture et cr\u00e9ativit\u00e9 territoriale\", item: \"Culture\", sub: \"projets culturels participatifs\", desc: \"Structure cit\u00e9e dans le PDF sur les projets culturels participatifs.\" },\r\n            { id: 32, name: \"Bouture Culturelle\", domain: \"Culture et cr\u00e9ativit\u00e9 territoriale\", item: \"Culture\", sub: \"projets culturels participatifs\", desc: \"Structure associ\u00e9e aux projets culturels participatifs.\" }\r\n        ];\r\n\r\n        \/\/ Vid\u00e9os de pr\u00e9sentation tourn\u00e9es avec les associations (cha\u00eene YouTube SHL).\r\n        \/\/ Servent de secours quand la colonne \u00ab Vid\u00e9o \u00bb du Google Sheet est vide :\r\n        \/\/ d\u00e8s qu'un lien est renseign\u00e9 dans la feuille, c'est lui qui prime.\r\n        \/\/ Cl\u00e9s normalis\u00e9es par normalizeLookupValue (sans accents ni ponctuation).\r\n        const associationVideoLinks = {\r\n            \"les cols verts provence\": \"https:\/\/youtu.be\/fN8BeO-cKLc\",\r\n            \"les cols verts\": \"https:\/\/youtu.be\/fN8BeO-cKLc\",\r\n            \"cols verts provence\": \"https:\/\/youtu.be\/fN8BeO-cKLc\",\r\n            \"en majuscule\": \"https:\/\/youtu.be\/5ajbRhhseEI\",\r\n            \"ecole de la 2e chance\": \"https:\/\/youtu.be\/EpO_K-Fi548\",\r\n            \"ecole de la 2eme chance\": \"https:\/\/youtu.be\/EpO_K-Fi548\",\r\n            \"ecole de la deuxieme chance\": \"https:\/\/youtu.be\/EpO_K-Fi548\",\r\n            \"e2c\": \"https:\/\/youtu.be\/EpO_K-Fi548\",\r\n            \"hatoup\": \"https:\/\/youtube.com\/shorts\/eILCl86NA_o\",\r\n            \"fifrelin\": \"https:\/\/youtu.be\/DniqlqYC4OI\",\r\n            \"association fifrelin\": \"https:\/\/youtu.be\/DniqlqYC4OI\",\r\n            \"femmes a la barre\": \"https:\/\/youtu.be\/DniqlqYC4OI\"\r\n        };\r\n\r\n        function knownAssociationVideo(name) {\r\n            const key = normalizeLookupValue(name);\r\n            if (!key) return \"\";\r\n            if (associationVideoLinks[key]) return associationVideoLinks[key];\r\n            \/\/ Tol\u00e9rance sur les suffixes du Sheet (\u00ab Association X \u00bb, \u00ab X - Marseille \u00bb\u2026).\r\n            const hit = Object.keys(associationVideoLinks)\r\n                .filter(candidate => key.includes(candidate))\r\n                .sort((a, b) => b.length - a.length)[0];\r\n            return hit ? associationVideoLinks[hit] : \"\";\r\n        }\r\n\r\n        \/\/ Put here the normal Google Sheet URL linked to the Form.\r\n        \/\/ Use the \/spreadsheets\/d\/...\/edit link shared as \"anyone with the link can view\".\r\n        \/\/ Avoid the published \/spreadsheets\/d\/e\/2PACX...\/pub CSV link when opening this file locally.\r\n        \/\/ The Google Form URL itself cannot be used as a data source.\r\n        \/\/ Colonnes prises en charge: Horodateur, Nom de l'association, Logo, Contact Nom,\r\n        \/\/ Email, T\u00e9l\u00e9phone, Zone, Public cible, Description, Financement d\u00e9pos\u00e9,\r\n        \/\/ Type de financement, Pays partenaire, Montant, Interlocuteur M\u00e9tropole,\r\n        \/\/ Domaine, Th\u00e9matique, Sous-th\u00e9matique.\r\n        const associationsCsvUrl = \"https:\/\/docs.google.com\/spreadsheets\/d\/1eDXo9t4YS7ZTv5xaALRa6-m5nLgE-BZQmp_9Y49FLts\/edit?usp=sharing\";\r\n        const fundingReferenceYear = 2026;\r\n        const dataRefreshMs = 60000;\r\n        const demoZones = [\"Aix\", \"Marseille\", \"Paris\", \"Grenoble\"];\r\n\r\n        function configuredAssociationsCsvUrl() {\r\n            const value = associationsCsvUrl.trim();\r\n            if (!value || value === \"associationsCsvUrl\" || value === \"TON_LIEN_CSV\") return \"\";\r\n            return value;\r\n        }\r\n\r\n        fallbackAssociations.forEach((asso, index) => {\r\n            if (!asso.zone) asso.zone = demoZones[index % demoZones.length];\r\n        });\r\n\r\n        const useLocalFallbackAssociations = !configuredAssociationsCsvUrl();\r\n        let allAssociations = useLocalFallbackAssociations ? fallbackAssociations.map(asso => ({ ...asso })) : [];\r\n        let fundingCalculationRows = [];\r\n        let activeZone = \"Toutes\";\r\n        let associations = allAssociations.slice();\r\n\r\n        \/\/ Anneaux de financement \u2014 v5. Les programmes qui couvrent EXACTEMENT\r\n        \/\/ les m\u00eames quadrants de la roue (m\u00eame \u00ab espace \u00bb th\u00e9matique) sont\r\n        \/\/ fusionn\u00e9s en un seul anneau, \u00e0 l'int\u00e9rieur de leur cat\u00e9gorie. Le label\r\n        \/\/ liste alors les programmes regroup\u00e9s, s\u00e9par\u00e9s par \u00ab \/ \u00bb. Moins\r\n        \/\/ d'anneaux \u21d2 traits plus \u00e9pais et plus lisibles.\r\n        \/\/\r\n        \/\/ Chaque CAT\u00c9GORIE garde sa famille de couleur (rose = Programmes\r\n        \/\/ sectoriels, or = Coop\u00e9ration territoriale, turquoise = Fonds\r\n        \/\/ structurels). \u00c0 l'int\u00e9rieur d'une famille, les teintes sont nettement\r\n        \/\/ \u00e9cart\u00e9es (du fonc\u00e9 au clair) pour qu'on distingue chaque programme.\r\n        const fundingLayers = [\r\n            \/\/ \u2014 Programmes sectoriels : famille rose \/ framboise \u2014\r\n            {\r\n                mode: \"category\",\r\n                group: \"Programmes sectoriels\",\r\n                label: \"ERASMUS +\",\r\n                color: \"#f0a8bb\",\r\n                segments: [[1, 89], [91, 89], [181, 89], [271, 89]]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Programmes sectoriels\",\r\n                label: \"CERV\",\r\n                color: \"#d5587d\",\r\n                segments: [[1, 89], [271, 89]]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Programmes sectoriels\",\r\n                label: \"Europe Creative\",\r\n                color: \"#a8254e\",\r\n                segments: [[181, 89], [271, 89]]\r\n            },\r\n            \/\/ \u2014 Coop\u00e9ration territoriale : famille or \/ ambre \u2014\r\n            {\r\n                mode: \"category\",\r\n                group: \"Coop\u00e9ration territoriale\",\r\n                label: \"URBACT IV \/ CITY-TO-CITY \/ EUI\",\r\n                color: \"#d4a14c\",\r\n                segments: [[1, 89], [91, 89], [181, 89], [271, 89]]\r\n            },\r\n            \/\/ \u2014 Fonds structurels : famille turquoise (du fonc\u00e9 au clair) \u2014\r\n            {\r\n                \/\/ FSE+, FAMI et INTERREG couvrent les deux m\u00eames quadrants.\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"FSE+ \/ FAMI \/ INTERREG\",\r\n                color: \"#0d6f81\",\r\n                segments: [[1, 89], [271, 89]]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"LIFE\",\r\n                color: \"#1c95a6\",\r\n                segments: [[1, 89]]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"FEDER \/ FEADER\",\r\n                color: \"#34b5c4\",\r\n                segments: [[271, 89]]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"EU4Health\",\r\n                color: \"#62cdd8\",\r\n                segments: [[1, 89], [91, 89]]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"HORIZON EUROPE\",\r\n                color: \"#95dee6\",\r\n                segments: [[1, 89], [91, 89], [181, 89], [271, 89]]\r\n            }\r\n        ];\r\n\r\n        \/\/ 2. STATE\r\n        let selectedNode = null;\r\n        let selectedAsso = null;\r\n        let fundingMode = \"none\";\r\n        \/\/ La roue d\u00e9marre vide d'associations ; le bouton \"Afficher les associations\"\r\n        \/\/ bascule cet \u00e9tat pour r\u00e9v\u00e9ler les bulles de comptage.\r\n        let showAssociations = false;\r\n        const activeFundingCategories = {\r\n            \"Programmes sectoriels\": false,\r\n            \"Coop\u00e9ration territoriale\": false,\r\n            \"Fonds structurels\": false\r\n        };\r\n\r\n        \/\/ 3. SETUP\r\n        const chartContainer = document.getElementById('chart-container');\r\n        const tooltip = document.getElementById('tooltip');\r\n\r\n        function getChartSize() {\r\n            return {\r\n                width: chartContainer.clientWidth || window.innerWidth,\r\n                height: chartContainer.clientHeight || window.innerHeight\r\n            };\r\n        }\r\n\r\n        \/\/ IMPORTANT \u2014 Les d\u00e9cisions de MISE EN PAGE (compact vs desktop, tier)\r\n        \/\/ suivent le VIEWPORT, comme les media queries CSS, et NON la largeur de\r\n        \/\/ la zone de dessin (qui r\u00e9tr\u00e9cit avec la l\u00e9gende \/ la sidebar). Sans \u00e7a,\r\n        \/\/ vers 1000-1130px la l\u00e9gende fait passer la zone sous 900px : le JS se\r\n        \/\/ croit en \"compact\" alors que le CSS affiche encore la barre d'outils \u00e0\r\n        \/\/ gauche \u2192 la roue se recalcule petite et se d\u00e9cale sous les boutons.\r\n        \/\/ (Les dimensions de DESSIN, elles, continuent d'utiliser `width`\/`height`\r\n        \/\/ = la zone r\u00e9elle.)\r\n        function layoutVW() {\r\n            return window.innerWidth || document.documentElement.clientWidth || width;\r\n        }\r\n        function layoutVH() {\r\n            return window.innerHeight || document.documentElement.clientHeight || height;\r\n        }\r\n\r\n        function isCompactLayout() {\r\n            const vw = layoutVW();\r\n            const coarsePointer = window.matchMedia && window.matchMedia(\"(pointer: coarse)\").matches;\r\n            return vw <= 900 || (coarsePointer && vw <= 1180);\r\n        }\r\n\r\n        function screenTier() {\r\n            const w = layoutVW();\r\n            const h = layoutVH();\r\n            if (w <= 560) return \"phone\";\r\n            if (w <= 900) return \"tablet\";\r\n            if (w <= 1180 || h <= 760) return \"laptop\";\r\n            if (w >= 1500) return \"wide\";\r\n            return \"desktop\";\r\n        }\r\n\r\n        function getDesktopSidebarWidth(w = width) {\r\n            if (isCompactLayout(w)) return 0;\r\n            return Math.min(450, Math.max(340, w * 0.34));\r\n        }\r\n\r\n        function getSidebarLayoutReserve(w = width) {\r\n            if (!document.body.classList.contains(\"sidebar-open\") || isCompactLayout(w)) return 0;\r\n            return Math.min(getDesktopSidebarWidth(w), Math.max(0, w - 300));\r\n        }\r\n\r\n        function getCompactSidebarReserve(w = width, h = height) {\r\n            if (!document.body.classList.contains(\"sidebar-open\") || !isCompactLayout(w)) return 0;\r\n            return Math.min(h * 0.58, 430);\r\n        }\r\n\r\n        function getUsableChartWidth(w = width) {\r\n            return Math.max(300, w - getSidebarLayoutReserve(w));\r\n        }\r\n\r\n        \/\/ Rectangle occup\u00e9 par les GROUPES DE CONTR\u00d4LES (s\u00e9lecteurs, boutons,\r\n        \/\/ switch) en haut \u00e0 gauche, exprim\u00e9 dans le rep\u00e8re de la zone de dessin.\r\n        \/\/ On ignore volontairement le gros TITRE : il est tout en haut, l\u00e0 o\u00f9 le\r\n        \/\/ cercle est \u00e9troit, et ne le g\u00eane pas. Renvoie {right, bottom} (marges\r\n        \/\/ de respiration incluses) ou null s'il n'y a rien \u00e0 d\u00e9gager :\r\n        \/\/   \u2022 compact : la barre passe en pleine largeur en haut \u2192 g\u00e9r\u00e9 ailleurs.\r\n        \/\/   \u2022 fiche ouverte (selectedNode) : la roue est d\u00e9j\u00e0 zoom\u00e9e\/d\u00e9cal\u00e9e.\r\n        function getToolbarRect() {\r\n            if (isCompactLayout() || selectedNode || !chartContainer) return null;\r\n            const root = document.querySelector('#shl-roue-wp');\r\n            if (!root) return null;\r\n            const controls = root.querySelectorAll(\r\n                '.zone-select-group, .toggle-group, .filter-group, .assos-switch-group'\r\n            );\r\n            const c = chartContainer.getBoundingClientRect();\r\n            let right = 0, bottom = 0;\r\n            controls.forEach(el => {\r\n                if (el.offsetParent === null) return; \/\/ masqu\u00e9 (hidden \/ display:none)\r\n                const r = el.getBoundingClientRect();\r\n                if (r.width <= 0) return;\r\n                right = Math.max(right, r.right - c.left);\r\n                bottom = Math.max(bottom, r.bottom - c.top);\r\n            });\r\n            if (!(right > 0)) return null;\r\n            return { right: right + 14, bottom: bottom + 10 };\r\n        }\r\n\r\n        function fundingVisibleHeight(w = width, h = height) {\r\n            return Math.max(180, h - getCompactSidebarReserve(w, h));\r\n        }\r\n\r\n        \/\/ Placement de la roue en vue d'ensemble \/ financements (non-compact,\r\n        \/\/ sans fiche ouverte). R\u00e8gle simple et robuste : on CENTRE la roue dans\r\n        \/\/ la bande libre situ\u00e9e \u00e0 DROITE de la barre d'outils (qui flotte en haut\r\n        \/\/ \u00e0 gauche) et \u00e0 GAUCHE de la l\u00e9gende. Ainsi la roue est visuellement\r\n        \/\/ \u00e9quilibr\u00e9e entre la barre et la l\u00e9gende (ni trop \u00e0 gauche ni trop \u00e0\r\n        \/\/ droite) et aussi grande que possible (limit\u00e9e par la hauteur ou par la\r\n        \/\/ largeur de cette bande). `usable` exclut d\u00e9j\u00e0 la l\u00e9gende (flex).\r\n        \/\/ Renvoie { reach, radius, cx, cy } \u2014 reach = bord externe des anneaux,\r\n        \/\/ radius = rayon de la roue.\r\n        function overviewPlacement() {\r\n            const usable = getUsableChartWidth(width);\r\n            const visibleH = fundingVisibleHeight(width, height);\r\n            const funding = isFundingSelectionActive();\r\n            const margin = funding ? 6 : (Math.min(width, height) < 520 ? 10 : 22);\r\n            \/\/ Couronne r\u00e9serv\u00e9e aux anneaux. On la garde mod\u00e9r\u00e9e pour que le\r\n            \/\/ DISQUE color\u00e9 (depths 0-2, depth-3 masqu\u00e9 en mode financement)\r\n            \/\/ remplisse au maximum l'espace, tout en gardant des traits \u00e9pais.\r\n            const ringsReserve = 50;\r\n            const tb = getToolbarRect();\r\n            \/\/ On ne d\u00e9cale vers la droite (pour \u00e9quilibrer avec la l\u00e9gende) qu'en\r\n            \/\/ mode Financements, quand la l\u00e9gende est affich\u00e9e \u00e0 droite. En mode\r\n            \/\/ Projets (pas de l\u00e9gende), on centre normalement.\r\n            const left = (funding && tb) ? tb.right : 0;\r\n            const band = Math.max(120, usable - left);\r\n            const reach = Math.max(40, Math.min(band \/ 2 - margin, visibleH \/ 2 - margin));\r\n            const radius = funding ? Math.max(80, reach - ringsReserve) : Math.max(40, reach);\r\n            return { reach, radius, cx: left + band \/ 2, cy: visibleH \/ 2 };\r\n        }\r\n\r\n        \/\/ Bord externe des anneaux de financement (partag\u00e9 trac\u00e9 \/ centrage).\r\n        function getFundingOuterReach(w = width, h = height) {\r\n            if (isCompactLayout()) {\r\n                return Math.min(getUsableChartWidth(w), fundingVisibleHeight(w, h)) \/ 2 - 8;\r\n            }\r\n            return overviewPlacement().reach;\r\n        }\r\n\r\n        function getChartCenterX(w = width) {\r\n            if (!isCompactLayout() && !selectedNode) return overviewPlacement().cx;\r\n            return getUsableChartWidth(w) \/ 2;\r\n        }\r\n\r\n        function assoMetrics(w = width) {\r\n            const tier = screenTier(w);\r\n            if (tier === \"phone\") {\r\n                return { baseGap: 3, radialGap: 2, dotR: 7.5, labelOffset: 0, labelWidth: 0, extraScale: 0.08, cultureExtra: 2, padding: 3 };\r\n            }\r\n            if (tier === \"tablet\") {\r\n                return { baseGap: 7, radialGap: 4, dotR: 9, labelOffset: 0, labelWidth: 0, extraScale: 0.14, cultureExtra: 4, padding: 4 };\r\n            }\r\n            if (tier === \"laptop\") {\r\n                return { baseGap: 28, radialGap: 15, dotR: 8.5, labelOffset: 12, labelWidth: 118, extraScale: 0.68, cultureExtra: 14, padding: 14 };\r\n            }\r\n            if (tier === \"wide\") {\r\n                return { baseGap: 42, radialGap: 24, dotR: 10, labelOffset: 18, labelWidth: 220, extraScale: 1, cultureExtra: 28, padding: 18 };\r\n            }\r\n            return { baseGap: 34, radialGap: 19, dotR: 8.5, labelOffset: 15, labelWidth: 150, extraScale: 1, cultureExtra: 22, padding: 16 };\r\n        }\r\n\r\n        function getAssoOuterReserve(w = width) {\r\n            const m = assoMetrics(w);\r\n            const maxStackIndex = 3;\r\n            const crossStackBump = 26 * m.extraScale;\r\n            return m.baseGap + maxStackIndex * m.radialGap + 5 + crossStackBump + m.cultureExtra + m.dotR + m.padding;\r\n        }\r\n\r\n        function getAssoReserve(w) {\r\n            if (isCompactLayout(w)) return getAssoOuterReserve(w);\r\n            \/\/ Vertical reserve: dot + halo + small clearance for labels at top\/bottom angles\r\n            \/\/ (these sit above\/below the wheel, not extending sideways).\r\n            const { baseGap, radialGap, dotR, padding } = assoMetrics(w);\r\n            const maxStackIndex = 3;\r\n            const labelBuffer = padding;\r\n            return baseGap + maxStackIndex * radialGap + 5 + dotR + labelBuffer;\r\n        }\r\n\r\n        function getMaxAssoLabelWidth(w) {\r\n            \/\/ Max allowed label width before wrapping. Labels longer than this break across\r\n            \/\/ multiple lines, which keeps them inside the side reserve at all viewport sizes.\r\n            return assoMetrics(w).labelWidth || 0;\r\n        }\r\n\r\n        function getAssoHorizontalReserve(w) {\r\n            if (isCompactLayout(w)) return getAssoOuterReserve(w);\r\n            \/\/ Horizontal reserve: dot + label offset + max label width. Labels at horizontal\r\n            \/\/ angles (~3 o'clock \/ 9 o'clock) extend sideways so the wheel must leave this\r\n            \/\/ much room beside it on each side.\r\n            const { baseGap, radialGap, dotR, labelOffset } = assoMetrics(w);\r\n            const maxStackIndex = 3;\r\n            return baseGap + maxStackIndex * radialGap + 5 + dotR + labelOffset + getMaxAssoLabelWidth(w);\r\n        }\r\n\r\n        function wrapAssoName(name, maxWidthPx, charWidth) {\r\n            if (!name) return [''];\r\n            const words = name.split(\/\\s+\/).filter(w => w.length > 0);\r\n            if (!words.length) return [name];\r\n            const fits = (s) => s.length * charWidth <= maxWidthPx;\r\n            const lines = [];\r\n            let cur = '';\r\n            for (const w of words) {\r\n                const tentative = cur ? cur + ' ' + w : w;\r\n                if (!cur || fits(tentative)) {\r\n                    cur = tentative;\r\n                } else {\r\n                    lines.push(cur);\r\n                    cur = w;\r\n                }\r\n            }\r\n            if (cur) lines.push(cur);\r\n            return lines;\r\n        }\r\n\r\n        const associationShortLabels = {\r\n            \"Association AFIRELIM\": \"AFIRELIM\",\r\n            \"R\u00e9gie de quartier de Pont-de-l'Arc\": \"R\u00e9gie Pont-de-l'Arc\",\r\n            \"Substrat culturel et sportif de Port-de-Bouc\": \"Substrat Port-de-Bouc\",\r\n            \"Maison de cultures et de la jeunesse\": \"Maison cultures & jeunesse\",\r\n            \"Association Cultures et Fronti\u00e8res Marseille\": \"Cultures & Fronti\u00e8res\",\r\n            \"Les Points Sauvants - Th\u00e9\u00e2tre\": \"Points Sauvants\",\r\n            \"Centre social Nelson Mandela\": \"CS Nelson Mandela\"\r\n        };\r\n\r\n        function associationDisplayName(name) {\r\n            return associationShortLabels[name] || name || \"\";\r\n        }\r\n\r\n        function associationPlacementTuning(name) {\r\n            const lower = (name || \"\").toLowerCase();\r\n            if (lower.includes(\"pont-de-l'arc\")) {\r\n                return { radialBoost: 44, angleOffset: 0.035, keepNearDot: true };\r\n            }\r\n            if (lower.includes(\"maison de cultures\")) {\r\n                return { radialBoost: 42, angleOffset: -0.035, keepNearDot: true };\r\n            }\r\n            return { radialBoost: 0, angleOffset: 0, keepNearDot: false };\r\n        }\r\n\r\n        function hasActiveFundingFilters() {\r\n            return Object.values(activeFundingCategories).some(v => v);\r\n        }\r\n\r\n        function isFundingSelectionActive() {\r\n            \/\/ Renvoyer true en mode financement avec filtres actifs :\r\n            \/\/   1. cache le depth-3 (petits arcs) pour que les anneaux fins\r\n            \/\/      n'aillent pas par-dessus du contenu,\r\n            \/\/   2. d\u00e9clenche la branche d\u00e9di\u00e9e de getResponsiveRadius() qui ne\r\n            \/\/      r\u00e9serve plus qu'un petit liser\u00e9 (anneaux fins en v4).\r\n            return fundingMode === \"category\" && hasActiveFundingFilters() && !selectedNode;\r\n        }\r\n\r\n        function getResponsiveRadius(w, h) {\r\n            const usableWidth = getUsableChartWidth(w);\r\n            const visibleHeight = Math.max(180, h - getCompactSidebarReserve(w, h));\r\n            const margin = Math.min(w, h) < 520 ? 10 : 22;\r\n            if (isFundingSelectionActive()) {\r\n                if (isCompactLayout(w)) {\r\n                    \/\/ Compact : barre en haut, roue centr\u00e9e ; anneaux jusqu'au bord.\r\n                    const outerReach = Math.min(usableWidth, visibleHeight) \/ 2 - 8;\r\n                    return Math.max(80, outerReach - 30);\r\n                }\r\n                \/\/ Non-compact : placement \u00ab centr\u00e9 + descendu \u00bb (cf. overviewPlacement),\r\n                \/\/ qui maximise la roue ET le bord externe des anneaux sans d\u00e9border\r\n                \/\/ sur la barre d'outils.\r\n                return overviewPlacement().radius;\r\n            }\r\n            if (isCompactLayout(w)) {\r\n                const topReserve = screenTier(w, h) === \"phone\" ? 68 : 78;\r\n                const bottomReserve = 16;\r\n                const availableH = Math.max(180, visibleHeight - topReserve - bottomReserve);\r\n                return Math.max(86, Math.min(w \/ 2 - margin - 10, availableH \/ 2 - margin));\r\n            }\r\n            \/\/ Non-compact, vue d'ensemble : roue centr\u00e9e, d\u00e9cal\u00e9e vers le bas pour\r\n            \/\/ d\u00e9gager la barre d'outils tout en restant la plus grande possible.\r\n            return overviewPlacement().radius;\r\n        }\r\n\r\n        function getZoomRadius() {\r\n            const topSpace = getZoomTopSpace();\r\n            const usableWidth = getUsableChartWidth(width);\r\n            const visibleHeight = Math.max(180, height - getCompactSidebarReserve(width, height));\r\n            const sideReserve = isCompactLayout(width) ? getAssoOuterReserve(width) + 6 : Math.min(usableWidth * 0.18, 360);\r\n            const widthRadius = usableWidth \/ 2 - sideReserve;\r\n            const heightRadius = isCompactLayout(width)\r\n                ? visibleHeight - topSpace - getAssoOuterReserve(width) - 14\r\n                : visibleHeight - topSpace - 34;\r\n            if (isCompactLayout(width)) {\r\n                return Math.max(72, Math.min(widthRadius, heightRadius));\r\n            }\r\n            const minZoomRadius = getSidebarLayoutReserve(width) > 0 ? 72 : radius;\r\n            return Math.max(minZoomRadius, Math.min(widthRadius, heightRadius));\r\n        }\r\n\r\n        function getZoomTopSpace() {\r\n            if (isCompactLayout(width)) return Math.min(Math.max(height * 0.13, 78), 112);\r\n            return Math.min(Math.max(height * 0.12, 52), 104);\r\n        }\r\n\r\n        function getActiveRadius(isZoomed) {\r\n            return isZoomed ? getZoomRadius() : radius;\r\n        }\r\n\r\n        function getChartCenterY(isZoomed) {\r\n            const compactBottomReserve = getCompactSidebarReserve(width, height);\r\n            const visibleHeight = Math.max(180, height - compactBottomReserve);\r\n            if (!isZoomed) {\r\n                \/\/ Non-compact (sans fiche) : on descend la roue pour exploiter\r\n                \/\/ l'espace sous la barre d'outils et la garder centr\u00e9e en largeur.\r\n                if (!isCompactLayout() && !selectedNode) return overviewPlacement().cy;\r\n                return visibleHeight \/ 2;\r\n            }\r\n            const activeRadius = getActiveRadius(true);\r\n            const topSpace = getZoomTopSpace();\r\n            const bottomMargin = isCompactLayout(width) ? 18 : (Math.min(height, width) < 520 ? 16 : 28);\r\n            const targetY = activeRadius + topSpace;\r\n            return Math.min(Math.max(targetY, visibleHeight \/ 2), visibleHeight - bottomMargin);\r\n        }\r\n\r\n        let { width, height } = getChartSize();\r\n        let radius = getResponsiveRadius(width, height);\r\n\r\n        const svg = d3.select(\"#chart-container\")\r\n            .append(\"svg\")\r\n            .attr(\"viewBox\", `0 0 ${width} ${height}`)\r\n            .style(\"font\", \"12px 'Manrope'\")\r\n            .style(\"overflow\", \"visible\");\r\n\r\n        const g = svg.append(\"g\").attr(\"transform\", `translate(${getChartCenterX()},${getChartCenterY(false)})`);\r\n        const partition = d3.partition().size([2 * Math.PI, radius]);\r\n        \/\/ Preserve the data order so the four main themes stay in their intended quadrants:\r\n        \/\/ Coh\u00e9sion top-right, Jeunesse bottom-right, Culture bottom-left, Transition top-left.\r\n        const root = d3.hierarchy(hierarchyData).sum(d => d.value);\r\n        partition(root);\r\n        applyRadialLayout(root, radius);\r\n\r\n        function applyRadialLayout(rootNode, activeRadius) {\r\n            const depthBands = [0, 0.22, 0.54, 0.79, 1];\r\n            rootNode.each(d => {\r\n                const start = depthBands[d.depth] ?? depthBands[depthBands.length - 2];\r\n                const end = depthBands[d.depth + 1] ?? depthBands[depthBands.length - 1];\r\n                d.y0 = start * activeRadius;\r\n                d.y1 = end * activeRadius;\r\n            });\r\n        }\r\n\r\n        function normalizeLookupValue(value) {\r\n            return String(value || \"\")\r\n                .normalize(\"NFD\")\r\n                .replace(\/[\\u0300-\\u036f]\/g, \"\")\r\n                .replace(\/&\/g, \" et \")\r\n                .toLowerCase()\r\n                .replace(\/[^a-z0-9]+\/g, \" \")\r\n                .trim()\r\n                .split(\/\\s+\/)\r\n                .filter(word => word && word !== \"et\")\r\n                .join(\" \");\r\n        }\r\n\r\n        const lookupValueAliases = new Map([\r\n            [\"lutte decrochage scolaire\", \"lutte contre decrochage scolaire\"],\r\n            [\"lutte contre le decrochage scolaire\", \"lutte contre decrochage scolaire\"],\r\n            [\"projets pedagogiques eu\", \"projets pedagogiques europeens\"],\r\n            [\"projet pedagogiques europeens\", \"projets pedagogiques europeens\"],\r\n            [\"projet pedagogique innovant\", \"projets educatifs innovants\"],\r\n            [\"projet educatif innovant\", \"projets educatifs innovants\"],\r\n            [\"veille d infos\", \"visites d etude\"],\r\n            [\"veilles d infos\", \"visites d etude\"],\r\n            [\"veilles infos\", \"visites d etude\"],\r\n            [\"formation pro\", \"formation professionnelle\"],\r\n            [\"accompagnement emploi\", \"accompagnement vers l emploi\"],\r\n            [\"accompagnement vers emploi\", \"accompagnement vers l emploi\"],\r\n            [\"entrepreneuriat jeunes\", \"entrepreneuriat des jeunes\"],\r\n            [\"formations pro\", \"formation professionnelle\"],\r\n            [\"festival culturel\", \"festivals culturels\"],\r\n            [\"atelier artistique\", \"ateliers artistiques\"],\r\n            [\"projet artistique\", \"projets artistiques\"],\r\n            [\"art quartier\", \"projets art quartiers\"],\r\n            [\"arret quartier\", \"projets art quartiers\"],\r\n            [\"projet art quartier\", \"projets art quartiers\"],\r\n            [\"projet art quartiers\", \"projets art quartiers\"],\r\n            [\"creation collaboration\", \"creations collaboratives\"],\r\n            [\"culture education\", \"projets culture education\"],\r\n            [\"projet culture education\", \"projets culture education\"],\r\n            [\"residence artistique\", \"residences d artistes\"],\r\n            [\"debat citoyens\", \"debats citoyens\"],\r\n            [\"jeunesse education decorchage scolaire\", \"lutte contre decrochage scolaire\"],\r\n            [\"decrochage\", \"lutte contre decrochage scolaire\"],\r\n            [\"decrochage scolaire\", \"lutte contre decrochage scolaire\"],\r\n            [\"echange jeune\", \"echanges de jeunes\"],\r\n            [\"echange de jeune\", \"echanges de jeunes\"],\r\n            [\"mobilite jeune\", \"mobilites europeennes\"],\r\n            [\"mobilite europenne\", \"mobilites europeennes\"],\r\n            [\"mobilite europpenne\", \"mobilites europeennes\"],\r\n            [\"projet sportif\", \"projets sportifs inclusifs\"],\r\n            [\"projet sportif inclusif\", \"projets sportifs inclusifs\"],\r\n            [\"sport\", \"projets sportifs inclusifs\"],\r\n            [\"projet climat\", \"projets climat\"],\r\n            [\"sensibilisation\", \"sensibilisation environnementale\"],\r\n            [\"public vulnerable\", \"accompagnement des publics vulnerables\"],\r\n            [\"accompagnement public vulnerable\", \"accompagnement des publics vulnerables\"],\r\n            [\"inertion sociale\", \"insertion sociale\"],\r\n            [\"projet intergenerationnel\", \"projets intergenerationnels\"],\r\n            [\"projet intergenerationel\", \"projets intergenerationnels\"],\r\n            [\"projet intergereationnel\", \"projets intergenerationnels\"],\r\n            [\"intergenerationnel\", \"projets intergenerationnels\"],\r\n            [\"mediation social\", \"mediation sociale\"],\r\n            [\"meditation sociale\", \"mediation sociale\"],\r\n            [\"mediation habitant\", \"mediation aupres des habitants\"],\r\n            [\"meditation habitant\", \"mediation aupres des habitants\"],\r\n            [\"nouveaux service numerique\", \"nouveaux services locaux\"],\r\n            [\"tier lieu innovation social\", \"tiers lieux\"],\r\n            [\"experimentation\", \"experimentations sociales\"],\r\n            [\"economie social solidaire\", \"economie sociale solidaire\"],\r\n            [\"circuits court\", \"circuits courts locaux\"],\r\n            [\"circuit court\", \"circuits courts locaux\"],\r\n            [\"lutte contre les dicri\", \"campagnes de sensibilisation\"],\r\n            [\"campagne de sensibilisation\", \"campagnes de sensibilisation\"]\r\n        ]);\r\n\r\n        function canonicalLookupValue(value) {\r\n            const key = normalizeLookupValue(value);\r\n            return lookupValueAliases.get(key) || key;\r\n        }\r\n\r\n        const taxonomy = {\r\n            cohesion: \"Coh\u00e9sion sociale & citoyennet\u00e9\",\r\n            jeunesse: \"Jeunesse, \u00e9ducation et comp\u00e9tences\",\r\n            culture: \"Culture et cr\u00e9ativit\u00e9 territoriale\",\r\n            transition: \"Transition \u00e9cologique et innovation sociale\"\r\n        };\r\n\r\n        function tax(domain, item, sub) {\r\n            return { domain, item, sub };\r\n        }\r\n\r\n        const taxonomyBySubKey = new Map([\r\n            [\"festival culturel\", tax(taxonomy.culture, \"Culture\", \"festivals culturels\")],\r\n            [\"atelier artistique\", tax(taxonomy.culture, \"M\u00e9diation culturelle\", \"ateliers artistiques\")],\r\n            [\"art quartier\", tax(taxonomy.culture, \"Projets artistiques\", \"projets art et quartiers\")],\r\n            [\"projet art quartier\", tax(taxonomy.culture, \"Projets artistiques\", \"projets art et quartiers\")],\r\n            [\"projet art quartiers\", tax(taxonomy.culture, \"Projets artistiques\", \"projets art et quartiers\")],\r\n            [\"projets art quartiers\", tax(taxonomy.culture, \"Projets artistiques\", \"projets art et quartiers\")],\r\n            [\"arret quartier\", tax(taxonomy.culture, \"Projets artistiques\", \"projets art et quartiers\")],\r\n            [\"creation collaboration\", tax(taxonomy.culture, \"Projets artistiques\", \"cr\u00e9ations collaboratives\")],\r\n            [\"culture education\", tax(taxonomy.culture, \"M\u00e9diation culturelle\", \"projets culture-\u00e9ducation\")],\r\n            [\"debat citoyens\", tax(taxonomy.cohesion, \"Participation citoyenne\", \"d\u00e9bats citoyens\")],\r\n            [\"jeunesse education decorchage scolaire\", tax(taxonomy.jeunesse, \"\u00c9ducation\", \"lutte contre d\u00e9crochage scolaire\")],\r\n            [\"decrochage\", tax(taxonomy.jeunesse, \"\u00c9ducation\", \"lutte contre d\u00e9crochage scolaire\")],\r\n            [\"decrochage scolaire\", tax(taxonomy.jeunesse, \"\u00c9ducation\", \"lutte contre d\u00e9crochage scolaire\")],\r\n            [\"echange jeune\", tax(taxonomy.jeunesse, \"Jeunesse\", \"\u00e9changes de jeunes\")],\r\n            [\"echange de jeune\", tax(taxonomy.jeunesse, \"Jeunesse\", \"\u00e9changes de jeunes\")],\r\n            [\"economie social solidaire\", tax(taxonomy.transition, \"Nouveaux mod\u00e8les \u00e9conomiques\", \"\u00e9conomie sociale et solidaire\")],\r\n            [\"projets p\u00e9dagogiques europ\u00e9ens\", tax(taxonomy.jeunesse, \"\u00c9ducation\", \"projets p\u00e9dagogiques europ\u00e9ens\")],\r\n            [\"experimentation\", tax(taxonomy.transition, \"Innovation sociale\", \"exp\u00e9rimentations sociales\")],\r\n            [\"experimentation sociale\", tax(taxonomy.transition, \"Innovation sociale\", \"exp\u00e9rimentations sociales\")],\r\n            [\"inclusion numerique\", tax(taxonomy.transition, \"Projets num\u00e9riques\", \"inclusion num\u00e9rique\")],\r\n            [\"inertion sociale\", tax(taxonomy.cohesion, \"Inclusion sociale\", \"insertion sociale\")],\r\n            [\"insertion sociale\", tax(taxonomy.cohesion, \"Inclusion sociale\", \"insertion sociale\")],\r\n            [\"intergenerationnel\", tax(taxonomy.cohesion, \"Inclusion sociale\", \"projets interg\u00e9n\u00e9rationnels\")],\r\n            [\"projet intergenerationnel\", tax(taxonomy.cohesion, \"Inclusion sociale\", \"projets interg\u00e9n\u00e9rationnels\")],\r\n            [\"projet intergereationnel\", tax(taxonomy.cohesion, \"Inclusion sociale\", \"projets interg\u00e9n\u00e9rationnels\")],\r\n            [\"lutte contre les dicri\", tax(taxonomy.cohesion, \"Lutte contre les discriminations\", \"campagnes de sensibilisation\")],\r\n            [\"mediation social\", tax(taxonomy.cohesion, \"Acc\u00e8s aux droits\", \"m\u00e9diation sociale\")],\r\n            [\"meditation sociale\", tax(taxonomy.cohesion, \"Acc\u00e8s aux droits\", \"m\u00e9diation sociale\")],\r\n            [\"mobilite europenne\", tax(taxonomy.jeunesse, \"Mobilit\u00e9\", \"mobilit\u00e9s europ\u00e9ennes\")],\r\n            [\"mobilite europpenne\", tax(taxonomy.jeunesse, \"Mobilit\u00e9\", \"mobilit\u00e9s europ\u00e9ennes\")],\r\n            [\"mobilite jeune\", tax(taxonomy.jeunesse, \"Mobilit\u00e9\", \"mobilit\u00e9s europ\u00e9ennes\")],\r\n            [\"nouveaux service numerique\", tax(taxonomy.transition, \"Innovation sociale\", \"nouveaux services locaux\")],\r\n            [\"projet climat\", tax(taxonomy.transition, \"Transition \u00e9cologique\", \"projets climat\")],\r\n            [\"projet sportif\", tax(taxonomy.jeunesse, \"Jeunesse\", \"projets sportifs inclusifs\")],\r\n            [\"sport\", tax(taxonomy.jeunesse, \"Jeunesse\", \"projets sportifs inclusifs\")],\r\n            [\"public vulnerable\", tax(taxonomy.cohesion, \"Inclusion sociale\", \"accompagnement des publics vuln\u00e9rables\")],\r\n            [\"tier lieu innovation social\", tax(taxonomy.transition, \"Innovation sociale\", \"tiers-lieux\")],\r\n            [\"circuits court\", tax(taxonomy.transition, \"Nouveaux mod\u00e8les \u00e9conomiques\", \"circuits courts locaux\")],\r\n            [\"circuit court\", tax(taxonomy.transition, \"Nouveaux mod\u00e8les \u00e9conomiques\", \"circuits courts locaux\")]\r\n        ].map(([key, value]) => [normalizeLookupValue(key), value]));\r\n\r\n        function normalizedTextHasAny(value, terms) {\r\n            const key = normalizeLookupValue(value);\r\n            return terms.some(term => key.includes(normalizeLookupValue(term)));\r\n        }\r\n\r\n        function inferCanonicalTaxonomy(asso) {\r\n            const rawDomain = asso.rawDomain || asso.domain;\r\n            const rawItem = asso.rawItem || asso.item;\r\n            const rawSub = asso.rawSub || asso.sub;\r\n            const subKey = normalizeLookupValue(rawSub);\r\n            const combined = [rawDomain, rawItem, rawSub, asso.name].filter(Boolean).join(\" \");\r\n            const ecologicalContext = normalizedTextHasAny(combined, [\r\n                \"\u00e9cologie\", \"ecologie\", \"environnement\", \"transition\", \"d\u00e9veloppement durable\",\r\n                \"developpement durable\", \"d\u00e9chets\", \"dechets\", \"recyclage\", \"biodiversit\u00e9\", \"biodiversite\"\r\n            ]);\r\n\r\n            if (subKey === \"sensibilisation\") {\r\n                return ecologicalContext\r\n                    ? tax(taxonomy.transition, \"Transition \u00e9cologique\", \"sensibilisation environnementale\")\r\n                    : tax(taxonomy.cohesion, \"Lutte contre les discriminations\", \"campagnes de sensibilisation\");\r\n            }\r\n\r\n            if (subKey === \"mediation habitant\" || subKey === \"meditation habitant\") {\r\n                return ecologicalContext\r\n                    ? tax(taxonomy.transition, \"Transition \u00e9cologique\", \"sensibilisation environnementale\")\r\n                    : tax(taxonomy.culture, \"M\u00e9diation culturelle\", \"m\u00e9diation aupr\u00e8s des habitants\");\r\n            }\r\n\r\n            if (subKey === \"formation\") {\r\n                return normalizedTextHasAny(combined, [\"discrimination\", \"discri\", \"\u00e9galit\u00e9\", \"egalite\"])\r\n                    ? tax(taxonomy.cohesion, \"Lutte contre les discriminations\", \"formations \u00e0 l\u2019\u00e9galit\u00e9\")\r\n                    : tax(taxonomy.jeunesse, \"Insertion\", \"formation professionnelle\");\r\n            }\r\n\r\n            if (taxonomyBySubKey.has(subKey)) return taxonomyBySubKey.get(subKey);\r\n\r\n            if (normalizedTextHasAny(combined, [\"mobilit\u00e9\", \"mobilite\", \"\u00e9change\", \"echange\"])) {\r\n                return tax(taxonomy.jeunesse, \"Mobilit\u00e9\", \"mobilit\u00e9s europ\u00e9ennes\");\r\n            }\r\n            if (normalizedTextHasAny(combined, [\"entrepreneuriat\", \"entrepreneur\"])) {\r\n                return tax(taxonomy.jeunesse, \"Insertion\", \"entrepreneuriat des jeunes\");\r\n            }\r\n            if (normalizedTextHasAny(combined, [\"sport\", \"plong\u00e9e\", \"plongee\"])) {\r\n                return tax(taxonomy.jeunesse, \"Jeunesse\", \"projets sportifs inclusifs\");\r\n            }\r\n            if (ecologicalContext) {\r\n                return tax(taxonomy.transition, \"Transition \u00e9cologique\", \"sensibilisation environnementale\");\r\n            }\r\n            if (normalizedTextHasAny(combined, [\"num\u00e9rique\", \"numerique\", \"ia\", \"m\u00e9dia\", \"media\"])) {\r\n                return tax(taxonomy.transition, \"Projets num\u00e9riques\", \"inclusion num\u00e9rique\");\r\n            }\r\n            if (normalizedTextHasAny(combined, [\"cin\u00e9ma\", \"cinema\", \"audiovisuel\", \"artistique\", \"arts\", \"spectacle\", \"culture\", \"photographie\", \"photographique\"])) {\r\n                return tax(taxonomy.culture, \"Projets artistiques\", \"cr\u00e9ations collaboratives\");\r\n            }\r\n            if (normalizedTextHasAny(combined, [\"insertion\", \"handicap\", \"vuln\u00e9rable\", \"vulnerable\", \"sociale\", \"social\"])) {\r\n                return tax(taxonomy.cohesion, \"Inclusion sociale\", \"insertion sociale\");\r\n            }\r\n            if (normalizedTextHasAny(combined, [\"\u00e9ducation\", \"education\", \"scolaire\", \"parentalit\u00e9\", \"parentalite\", \"artisanat\"])) {\r\n                return tax(taxonomy.jeunesse, \"\u00c9ducation\", \"projets \u00e9ducatifs innovants\");\r\n            }\r\n            if (normalizedTextHasAny(combined, [\"citoyen\", \"citoyenne\", \"participation\"])) {\r\n                return tax(taxonomy.cohesion, \"Participation citoyenne\", \"d\u00e9bats citoyens\");\r\n            }\r\n            return null;\r\n        }\r\n\r\n        function associationTaxonomyLooksCanonical(asso) {\r\n            const domainKey = canonicalLookupValue(asso.domain);\r\n            const itemKey = canonicalLookupValue(asso.item);\r\n            const subKey = canonicalLookupValue(asso.sub);\r\n            if (!domainKey && !itemKey && !subKey) return false;\r\n            return root.descendants().some(d => {\r\n                if (d.depth !== 3) return false;\r\n                const nodeDomain = canonicalLookupValue(d.parent.parent.data.name);\r\n                const nodeItem = canonicalLookupValue(d.parent.data.name);\r\n                const nodeSub = canonicalLookupValue(d.data.name);\r\n                const domainMatches = !domainKey || domainKey === nodeDomain;\r\n                const itemMatches = !itemKey || itemKey === nodeItem;\r\n                if (subKey && subKey === nodeSub && domainMatches && itemMatches) return true;\r\n                return Boolean(domainKey && itemKey && domainMatches && itemMatches);\r\n            });\r\n        }\r\n\r\n        function canonicalizeExplicitAssociationTaxonomy(asso) {\r\n            const rawDomain = String(asso.rawDomain || asso.domain || \"\").trim();\r\n            const rawItem = String(asso.rawItem || asso.item || \"\").trim();\r\n            const rawSub = String(asso.rawSub || asso.sub || \"\").trim();\r\n            const domainKey = canonicalLookupValue(rawDomain);\r\n            const domainNode = root.children.find(d => canonicalLookupValue(d.data.name) === domainKey);\r\n            const domain = domainNode ? domainNode.data.name : \"\";\r\n            let item = rawItem ? findCanonicalItemName(rawItem, domain) : \"\";\r\n            let subNode = rawSub ? findCanonicalSubNode(rawSub, domain, item) : null;\r\n\r\n            \/\/ If the sub-theme alone is enough to find a canonical leaf, keep it.\r\n            \/\/ This protects explicit Excel rows from being reclassified by keyword inference.\r\n            if (!subNode && rawSub) {\r\n                subNode = findCanonicalSubNode(rawSub, domain, \"\");\r\n            }\r\n\r\n            if (subNode) {\r\n                return {\r\n                    domain: subNode.parent.parent.data.name,\r\n                    item: subNode.parent.data.name,\r\n                    sub: subNode.data.name,\r\n                    specific: true\r\n                };\r\n            }\r\n\r\n            if (domain && item) {\r\n                return { domain, item, sub: rawSub, specific: true };\r\n            }\r\n\r\n            if (domain) {\r\n                return { domain, item: rawItem, sub: rawSub, specific: false };\r\n            }\r\n\r\n            if (item) {\r\n                return { domain: \"\", item, sub: rawSub, specific: true };\r\n            }\r\n\r\n            return null;\r\n        }\r\n\r\n        function normalizeAssociationTaxonomy(asso) {\r\n            const normalized = { ...asso };\r\n            if (!normalized.rawDomain) normalized.rawDomain = normalized.domain || \"\";\r\n            if (!normalized.rawItem) normalized.rawItem = normalized.item || \"\";\r\n            if (!normalized.rawSub) normalized.rawSub = normalized.sub || \"\";\r\n\r\n            if (associationTaxonomyLooksCanonical(normalized)) return normalized;\r\n\r\n            const explicitTaxonomy = canonicalizeExplicitAssociationTaxonomy(normalized);\r\n            if (explicitTaxonomy) {\r\n                normalized.domain = explicitTaxonomy.domain || normalized.domain;\r\n                normalized.item = explicitTaxonomy.item || normalized.item;\r\n                normalized.sub = explicitTaxonomy.sub || normalized.sub;\r\n                if (explicitTaxonomy.specific || associationTaxonomyLooksCanonical(normalized)) return normalized;\r\n            }\r\n\r\n            const inferred = inferCanonicalTaxonomy(normalized);\r\n            if (inferred) {\r\n                const explicitDomain = explicitTaxonomy && explicitTaxonomy.domain;\r\n                const inferredMatchesExplicitDomain = !explicitDomain ||\r\n                    canonicalLookupValue(inferred.domain) === canonicalLookupValue(explicitDomain);\r\n                if (inferredMatchesExplicitDomain) {\r\n                    normalized.domain = inferred.domain;\r\n                    normalized.item = inferred.item;\r\n                    normalized.sub = inferred.sub;\r\n                }\r\n            }\r\n            return normalized;\r\n        }\r\n\r\n        function findNodeForAsso(asso) {\r\n            const domainKey = canonicalLookupValue(asso.domain);\r\n            const itemKey = canonicalLookupValue(asso.item);\r\n            const subKey = canonicalLookupValue(asso.sub);\r\n            let candidates = root.descendants().filter(d => d.depth === 3);\r\n            const allCandidates = candidates;\r\n            let scopedByDomain = false;\r\n            if (domainKey) {\r\n                const domainCandidates = candidates.filter(d => canonicalLookupValue(d.parent.parent.data.name) === domainKey);\r\n                if (domainCandidates.length) {\r\n                    candidates = domainCandidates;\r\n                    scopedByDomain = true;\r\n                }\r\n            }\r\n            const inScopeMatch = candidates.find(d =>\r\n                canonicalLookupValue(d.data.name) === subKey &&\r\n                canonicalLookupValue(d.parent.data.name) === itemKey &&\r\n                (!domainKey || canonicalLookupValue(d.parent.parent.data.name) === domainKey)\r\n            ) || candidates.find(d =>\r\n                itemKey &&\r\n                canonicalLookupValue(d.parent.data.name) === itemKey\r\n            ) || candidates.find(d =>\r\n                subKey && canonicalLookupValue(d.data.name) === subKey\r\n            );\r\n            if (inScopeMatch || scopedByDomain) return inScopeMatch || null;\r\n            return allCandidates.find(d =>\r\n                itemKey && canonicalLookupValue(d.parent.data.name) === itemKey\r\n            ) || null;\r\n        }\r\n\r\n        function associationSeed(asso, index) {\r\n            const numericId = Number(String(asso.id || \"\").replace(\/\\D\/g, \"\"));\r\n            if (Number.isFinite(numericId) && numericId > 0) return numericId;\r\n            const source = `${asso.name || \"\"}|${asso.zone || \"\"}|${index}`;\r\n            let hash = 0;\r\n            for (let i = 0; i < source.length; i++) {\r\n                hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;\r\n            }\r\n            return Math.abs(hash) || (index + 1);\r\n        }\r\n\r\n        function staggerCloseAssociations(source) {\r\n            const valid = source.filter(a => a.node);\r\n            valid.forEach(asso => {\r\n                const node = asso.node;\r\n                const baseAngle = node.x0 + asso.angleRatio * (node.x1 - node.x0);\r\n                asso.absAngle = baseAngle + (asso.floatOffset || 0);\r\n                asso.extraRadial = 0;\r\n            });\r\n            const stackMap = new Map();\r\n            valid.forEach(asso => {\r\n                const key = `${asso.domain}|${asso.item}|${asso.sub}`;\r\n                if (!stackMap.has(key)) stackMap.set(key, []);\r\n                stackMap.get(key).push(asso);\r\n            });\r\n            const stacks = [...stackMap.values()].map(members => {\r\n                const angles = members.map(m => m.absAngle);\r\n                return {\r\n                    members,\r\n                    minAngle: Math.min(...angles),\r\n                    maxAngle: Math.max(...angles),\r\n                    midAngle: angles.reduce((s, a) => s + a, 0) \/ angles.length\r\n                };\r\n            }).sort((a, b) => a.midAngle - b.midAngle);\r\n            const MIN_ANGLE_GAP = 0.07;\r\n            const RADIAL_BUMP = 26;\r\n            for (let i = 1; i < stacks.length; i++) {\r\n                const prev = stacks[i - 1];\r\n                const curr = stacks[i];\r\n                if (curr.minAngle - prev.maxAngle < MIN_ANGLE_GAP) {\r\n                    const prevExtra = prev.members[0].extraRadial || 0;\r\n                    const newExtra = prevExtra + RADIAL_BUMP;\r\n                    curr.members.forEach(m => { m.extraRadial = newExtra; });\r\n                }\r\n            }\r\n        }\r\n\r\n        function prepareAssociations(source) {\r\n            source.forEach((asso, index) => {\r\n                asso.zone = normalizeZone(asso.zone);\r\n                asso.node = null;\r\n                asso.color = \"#64748b\";\r\n                asso.stackIndex = 0;\r\n                asso.stackSize = 1;\r\n                asso.floatOffset = 0;\r\n                asso.extraRadial = 0;\r\n\r\n                const targetNode = findNodeForAsso(asso);\r\n                if (!targetNode) return;\r\n\r\n                asso.domain = targetNode.parent.parent.data.name;\r\n                asso.item = targetNode.parent.data.name;\r\n                asso.sub = targetNode.data.name;\r\n                const seed = associationSeed(asso, index);\r\n                \/\/ Keep dots in the central area of the sub so they don't drift\r\n                \/\/ close to a neighbouring sub.\r\n                const anglePadding = 0.25;\r\n                const radiusPadding = 0.15;\r\n                asso.angleRatio = anglePadding + (((seed * 17) % 100) \/ 100) * (1 - 2 * anglePadding);\r\n                asso.radiusRatio = radiusPadding + (((seed * 23) % 100) \/ 100) * (1 - 2 * radiusPadding);\r\n                asso.node = targetNode;\r\n                asso.color = targetNode.parent.parent.data.color;\r\n            });\r\n\r\n            const associationStacks = new Map();\r\n            source.filter(a => a.node).forEach(asso => {\r\n                const key = `${asso.domain}|${asso.item}|${asso.sub}`;\r\n                const stack = associationStacks.get(key) || [];\r\n                stack.push(asso);\r\n                associationStacks.set(key, stack);\r\n            });\r\n            associationStacks.forEach(stack => {\r\n                stack.forEach((asso, index) => {\r\n                    asso.stackIndex = index;\r\n                    asso.stackSize = stack.length;\r\n                    asso.floatOffset = (index - (stack.length - 1) \/ 2) * 0.04;\r\n                });\r\n            });\r\n            staggerCloseAssociations(source);\r\n        }\r\n\r\n        if (allAssociations.length) {\r\n            allAssociations = allAssociations.map((asso, index) => normalizeAssociationTaxonomy({\r\n                ...asso,\r\n                id: asso.id || `asso-${index + 1}`,\r\n                zone: normalizeZone(asso.zone)\r\n            }));\r\n            fundingCalculationRows = buildFallbackFundingCalculationRows(allAssociations);\r\n            associations = allAssociations.slice();\r\n        }\r\n        prepareAssociations(associations);\r\n\r\n        const arc = d3.arc()\r\n            .startAngle(d => d.x0)\r\n            .endAngle(d => d.x1)\r\n            .padAngle(d => Math.min((d.x1 - d.x0) \/ 2, 0.005))\r\n            .padRadius(radius \/ 2)\r\n            .innerRadius(d => d.y0)\r\n            .outerRadius(d => d.y1 - 1);\r\n\r\n        const arcsGroup = g.append(\"g\").attr(\"class\", \"arcs\");\r\n        const textPathsGroup = g.append(\"defs\").attr(\"class\", \"text-paths\");\r\n        const assosGroup = g.append(\"g\").attr(\"class\", \"assos\");\r\n        const textGroup = g.append(\"g\").attr(\"class\", \"texts\").attr(\"pointer-events\", \"none\");\r\n        const domainTitleGroup = g.append(\"g\").attr(\"class\", \"domain-titles\").attr(\"pointer-events\", \"none\");\r\n        const fundingGroup = g.append(\"g\").attr(\"class\", \"funding\");\r\n\r\n        function fundingBandData(mode) {\r\n            return fundingRingData(mode).flatMap(layer => {\r\n                return layer.segments.map(([start, span], segmentIndex) => {\r\n                    const startAngle = (start * Math.PI) \/ 180;\r\n                    const endAngle = ((start + span) * Math.PI) \/ 180;\r\n                    return {\r\n                        ...layer,\r\n                        segmentIndex,\r\n                        pieceIndex: 0,\r\n                        startAngle,\r\n                        endAngle,\r\n                        midAngle: (startAngle + endAngle) \/ 2\r\n                    };\r\n                });\r\n            });\r\n        }\r\n\r\n        function getDomainQuadrantSegments() {\r\n            const domains = (root && root.children) || [];\r\n            const padding = 1;\r\n            return domains.map(d => {\r\n                const x0Deg = (d.x0 * 180) \/ Math.PI;\r\n                const x1Deg = (d.x1 * 180) \/ Math.PI;\r\n                return [x0Deg + padding, (x1Deg - x0Deg) - 2 * padding];\r\n            });\r\n        }\r\n\r\n        function fundingRingData(mode) {\r\n            if (mode !== \"category\") return [];\r\n            const layers = fundingLayers.filter(layer => layer.mode === mode);\r\n            const compact = isCompactLayout(width);\r\n            const ringGap = compact ? Math.max(2, Math.min(4, radius * 0.05)) : Math.max(4, Math.min(7, radius * 0.025));\r\n            const interRingGap = compact ? Math.max(1, Math.min(2, radius * 0.018)) : Math.max(1.5, Math.min(3, radius * 0.012));\r\n            \/\/ Limite externe des anneaux : coh\u00e9rente avec le d\u00e9calage de la roue\r\n            \/\/ (getChartCenterX) \u2014 on retire la zone r\u00e9serv\u00e9e \u00e0 la barre d'outils\r\n            \/\/ pour que les anneaux ne d\u00e9bordent ni \u00e0 gauche (boutons) ni \u00e0 droite.\r\n            const outerLimit = getFundingOuterReach(width, height);\r\n            \/\/ When in funding mode without a selected branch, depth 3 of the wheel is hidden,\r\n            \/\/ so rings can start from the depth 2 outer edge (0.79 \u00d7 radius) instead of the\r\n            \/\/ wheel's full radius. This gives the rings more room without changing their order.\r\n            const wheelOuterForRings = !selectedNode ? radius * 0.79 : radius;\r\n            const fitThickness = (outerLimit - wheelOuterForRings - ringGap - Math.max(0, layers.length - 1) * interRingGap) \/ Math.max(1, layers.length);\r\n            \/\/ v5 \u2014 Anneaux \u00e9pais : les noms sont dans la liste lat\u00e9rale\r\n            \/\/ (#funding-legend), donc chaque trait peut remplir toute la couronne\r\n            \/\/ r\u00e9serv\u00e9e. On laisse l'\u00e9paisseur grandir (\u2248 couronne \/ nb d'anneaux)\r\n            \/\/ jusqu'\u00e0 un plafond g\u00e9n\u00e9reux, sans toucher au rayon de la roue : les\r\n            \/\/ traits sont bien plus visibles mais le contenu int\u00e9rieur garde sa\r\n            \/\/ taille. Le plafond \u00e9vite seulement qu'ils deviennent disproportionn\u00e9s.\r\n            const ringThickness = compact\r\n                ? Math.max(4, Math.min(screenTier(width, height) === \"phone\" ? 9 : 12, fitThickness))\r\n                : Math.max(6, Math.min(20, fitThickness));\r\n            const ringStep = ringThickness + interRingGap;\r\n            const firstInner = wheelOuterForRings + ringGap;\r\n            const quadrants = getDomainQuadrantSegments();\r\n            return layers.map((layer, layerIndex) => {\r\n                const inner = firstInner + layerIndex * ringStep;\r\n                const outer = inner + ringThickness;\r\n                const segments = quadrants.length === 4\r\n                    ? layer.segments.map(([start, span]) => {\r\n                        const qIndex = Math.floor((((start + span \/ 2) % 360) + 360) % 360 \/ 90) % 4;\r\n                        return quadrants[qIndex];\r\n                    })\r\n                    : layer.segments;\r\n                return {\r\n                    ...layer,\r\n                    segments,\r\n                    ringIndex: layerIndex,\r\n                    innerRadius: inner,\r\n                    outerRadius: outer,\r\n                    labelRadius: inner + ringThickness \/ 2\r\n                };\r\n            });\r\n        }\r\n\r\n        function fundingTooltip(d) {\r\n            return `<strong>${d.label}<\/strong><br><em>${d.group}<\/em>`;\r\n        }\r\n\r\n        \/\/ v4 \u2014 L\u00e9gende verticale : pastille color\u00e9e + nom du programme,\r\n        \/\/ regroup\u00e9s par cat\u00e9gorie. Affich\u00e9e uniquement quand au moins une\r\n        \/\/ cat\u00e9gorie est coch\u00e9e (sinon retir\u00e9e pour laisser la roue respirer).\r\n        \/\/ Retourne true si la visibilit\u00e9 de la l\u00e9gende a chang\u00e9 \u2014 utile pour\r\n        \/\/ d\u00e9clencher un reflow de la roue dans la foul\u00e9e.\r\n        let __legendActive = false;\r\n        function renderFundingLegend() {\r\n            const legendEl = document.getElementById('funding-legend');\r\n            if (!legendEl) return false;\r\n            const categories = [\"Programmes sectoriels\", \"Coop\u00e9ration territoriale\", \"Fonds structurels\"];\r\n            const activeCats = fundingMode === \"category\"\r\n                ? categories.filter(cat => activeFundingCategories[cat])\r\n                : [];\r\n            const shouldBeActive = activeCats.length > 0;\r\n            const previouslyActive = __legendActive;\r\n            __legendActive = shouldBeActive;\r\n\r\n            if (!shouldBeActive) {\r\n                legendEl.classList.remove('is-active');\r\n                legendEl.innerHTML = \"\";\r\n                return previouslyActive !== shouldBeActive;\r\n            }\r\n\r\n            legendEl.innerHTML = activeCats.map(cat => {\r\n                const progs = fundingLayers.filter(l => l.mode === \"category\" && l.group === cat);\r\n                const items = progs.map(p => `\r\n                    <li class=\"funding-legend-item\">\r\n                        <span class=\"funding-legend-dot\" style=\"--dot-color:${p.color};\"><\/span>\r\n                        <span>${escapeHTML(p.label)}<\/span>\r\n                    <\/li>`).join(\"\");\r\n                return `\r\n                <section class=\"funding-legend-cat\">\r\n                    <h3 class=\"funding-legend-cat-title\">${escapeHTML(cat)}<\/h3>\r\n                    <ul class=\"funding-legend-list\">${items}<\/ul>\r\n                <\/section>`;\r\n            }).join(\"\");\r\n            legendEl.classList.add('is-active');\r\n            return previouslyActive !== shouldBeActive;\r\n        }\r\n\r\n        function labelOpacity(d, isZoomed, box = d.current) {\r\n            if (!box.visible) return 0;\r\n            if (box.x1 - box.x0 < 0.08) return 0;\r\n            if (d.depth === 1) return 0;\r\n            if (isCompactLayout(width)) {\r\n                if (d.depth === 3) return isZoomed ? 1 : 0;\r\n                return d.depth === 2 ? 1 : 0;\r\n            }\r\n            if (!isZoomed && d.depth >= 3) return 0;\r\n            return 1;\r\n        }\r\n\r\n        \/\/ =========================================================\r\n        \/\/ TEXT PATH BUILDER \u2014 fix: keep text readable on bottom half\r\n        \/\/ =========================================================\r\n        \/\/ Key insight: the baseline path goes clockwise for top-half segments\r\n        \/\/ (text reads left-to-right, upright), and counter-clockwise for bottom-half\r\n        \/\/ (text still reads left-to-right, upright but now \"below\" the curve).\r\n        \/\/ When the text is \"below\" the curve (bottom half), its baseline is at the top\r\n        \/\/ of the text, so the effective center of the glyph is at (r - fontSize\/2 roughly).\r\n        \/\/ To keep the visual baseline centered in the same way, shift the baseline radius\r\n        \/\/ outward on the bottom half by a small amount equal to the text x-height.\r\n        function isBottomHalf(x0, x1) {\r\n            const mid = (x0 + x1) \/ 2;\r\n            return (mid > Math.PI \/ 2 && mid < 3 * Math.PI \/ 2);\r\n        }\r\n\r\n        function buildTextPath(x0, x1, y0, y1, lineOffset = 0) {\r\n            if (x1 - x0 <= 0.0001) return `M 0 0 L 0 0`;\r\n            const bottom = isBottomHalf(x0, x1);\r\n            \/\/ Flip lineOffset sign on bottom half so multi-line text still stacks top-to-bottom\r\n            \/\/ relative to the reader's view (not relative to the circle geometry)\r\n            const effectiveOffset = bottom ? -lineOffset : lineOffset;\r\n            const r = Math.max(1, (y0 + y1) \/ 2 + effectiveOffset);\r\n\r\n            \/\/ For the top half, we draw the arc sweeping clockwise (sweep=1) from x0 to x1:\r\n            \/\/   \u2192 text sits on the outer side of the baseline (above the path in SVG y terms)\r\n            \/\/   \u2192 glyphs are upright, reads left-to-right from the viewer's perspective.\r\n            \/\/ For the bottom half, we draw counter-clockwise (sweep=0) from x1 to x0:\r\n            \/\/   \u2192 this effectively flips the baseline so the glyphs are upright for the viewer too.\r\n            let p0x, p0y, p1x, p1y, sweep;\r\n            if (!bottom) {\r\n                \/\/ Top half: path goes x0 \u2192 x1, sweep clockwise\r\n                sweep = 1;\r\n                p0x = r * Math.sin(x0); p0y = -r * Math.cos(x0);\r\n                p1x = r * Math.sin(x1); p1y = -r * Math.cos(x1);\r\n            } else {\r\n                \/\/ Bottom half: path goes x1 \u2192 x0, sweep counter-clockwise\r\n                \/\/ This makes the \"top\" of the text (baseline + ascender) face outward,\r\n                \/\/ toward the viewer, so characters stay upright.\r\n                sweep = 0;\r\n                p0x = r * Math.sin(x1); p0y = -r * Math.cos(x1);\r\n                p1x = r * Math.sin(x0); p1y = -r * Math.cos(x0);\r\n            }\r\n            const largeArc = (x1 - x0 > Math.PI) ? 1 : 0;\r\n            return `M ${p0x} ${p0y} A ${r} ${r} 0 ${largeArc} ${sweep} ${p1x} ${p1y}`;\r\n        }\r\n\r\n        function textArcPath(d, lineOffset = 0) {\r\n            return buildTextPath(d.x0, d.x1, d.y0, d.y1, lineOffset);\r\n        }\r\n\r\n        function labelLineConfig(d, box = d.current) {\r\n            const r = (box.y0 + box.y1) \/ 2;\r\n            const span = Math.max(0.001, box.x1 - box.x0);\r\n            const chordLen = Math.max(1, 2 * r * Math.sin(span \/ 2));\r\n            if (isCompactLayout(width)) {\r\n                if (d.depth === 2) {\r\n                    return {\r\n                        maxChars: chordLen < 150 ? 10 : 12,\r\n                        maxLines: 2\r\n                    };\r\n                }\r\n                if (d.depth === 3) {\r\n                    return {\r\n                        maxChars: 12,\r\n                        maxLines: 4\r\n                    };\r\n                }\r\n                return {\r\n                    maxChars: 10,\r\n                    maxLines: 1\r\n                };\r\n            }\r\n            if (d.depth === 1) {\r\n                return {\r\n                    maxChars: chordLen < 170 ? 13 : 15,\r\n                    maxLines: 3\r\n                };\r\n            }\r\n            if (d.depth === 2) {\r\n                return {\r\n                    maxChars: chordLen < 120 ? 12 : 14,\r\n                    maxLines: 3\r\n                };\r\n            }\r\n            return {\r\n                maxChars: chordLen < 92 ? 10 : 12,\r\n                maxLines: 4\r\n            };\r\n        }\r\n\r\n        function labelLines(name, d, box = d.current) {\r\n            const { maxChars, maxLines } = labelLineConfig(d, box);\r\n            const words = name.split(\/\\s+\/).filter(Boolean);\r\n            const lines = [];\r\n            let current = \"\";\r\n\r\n            words.forEach(word => {\r\n                const next = current ? `${current} ${word}` : word;\r\n                if (next.length > maxChars && current) {\r\n                    lines.push(current);\r\n                    current = word;\r\n                } else {\r\n                    current = next;\r\n                }\r\n            });\r\n            if (current) lines.push(current);\r\n\r\n            if (lines.length <= maxLines) return lines;\r\n            const kept = lines.slice(0, maxLines);\r\n            while (kept.length > 1 && kept[kept.length - 1].length < 4) {\r\n                const overflow = kept.pop();\r\n                kept[kept.length - 1] = `${kept[kept.length - 1]} ${overflow}`;\r\n            }\r\n            return kept;\r\n        }\r\n\r\n        function labelLineHeight(d, fontSize) {\r\n            \/\/ Hauteur de ligne proportionnelle \u00e0 la police pour \u00e9viter les chevauchements\r\n            \/\/ quand la roue est r\u00e9tr\u00e9cie (sidebar ouverte, \u00e9cran laptop, etc.).\r\n            if (d && d.depth === 2) return Math.max(fontSize * 1.22, fontSize + 2.5);\r\n            if (d && d.depth === 3) return Math.max(fontSize * 1.28, fontSize + 2.8);\r\n            return Math.max(fontSize * 1.22, fontSize + 2.5);\r\n        }\r\n\r\n        function labelLineOffset(lineIndex, lineCount, fontSize, d = null) {\r\n            const lineHeight = labelLineHeight(d, fontSize);\r\n            return ((lineCount - 1) \/ 2 - lineIndex) * lineHeight;\r\n        }\r\n\r\n        function computeLabelFontSize(d, isZoomed, box = d.current) {\r\n            const r = (box.y0 + box.y1) \/ 2;\r\n            const span = Math.max(0.001, box.x1 - box.x0);\r\n            const arcLen = Math.max(1, 2 * r * Math.sin(span \/ 2));\r\n            const longestLine = Math.max(...labelLines(d.data.name, d, box).map(line => line.length));\r\n            if (isCompactLayout(width)) {\r\n                const compactRadius = getActiveRadius(isZoomed);\r\n                const compactBase = Math.max(6.5, Math.min(11.5, compactRadius * 0.044));\r\n                if (d.depth === 2) {\r\n                    const maxFs = compactBase * 0.98;\r\n                    const minFs = compactBase * 0.66;\r\n                    const widthFactor = 0.58;\r\n                    const usableWidth = 0.8;\r\n                    const estW = longestLine * (maxFs * widthFactor);\r\n                    if (estW <= arcLen * usableWidth) return maxFs;\r\n                    return Math.max(minFs, (arcLen * usableWidth) \/ (longestLine * widthFactor));\r\n                }\r\n                if (d.depth === 3) {\r\n                    const maxFs = compactBase * 0.72;\r\n                    const minFs = compactBase * 0.48;\r\n                    const widthFactor = 0.54;\r\n                    const usableWidth = 0.82;\r\n                    const estW = longestLine * (maxFs * widthFactor);\r\n                    if (estW <= arcLen * usableWidth) return maxFs;\r\n                    return Math.max(minFs, (arcLen * usableWidth) \/ (longestLine * widthFactor));\r\n                }\r\n                return 0;\r\n            }\r\n            \/\/ Tailles proportionnelles au rayon r\u00e9el du graphique (responsive\r\n            \/\/ qualitatif : laptop, WordPress, etc.). On clamp pour rester lisible\r\n            \/\/ dans tous les contextes. Quand la sidebar est ouverte sur laptop,\r\n            \/\/ on r\u00e9duit encore le multiplicateur pour \u00e9viter le d\u00e9bordement.\r\n            const activeRadius = getActiveRadius(isZoomed);\r\n            const sidebarOpen = document.body.classList.contains(\"sidebar-open\");\r\n            const constrained = sidebarOpen && !isCompactLayout(width);\r\n            const fsMultiplier = constrained ? 0.034 : 0.040;\r\n            const fsBaseMax = constrained ? 13 : 16;\r\n            const fsBase = Math.max(7, Math.min(fsBaseMax, activeRadius * fsMultiplier));\r\n            \/\/ Zoom\u00e9 : cat\u00e9gories (depth 2) et sous-cat\u00e9gories (depth 3) doivent\r\n            \/\/ rester SOUS le titre du th\u00e8me (cf. domainTitleLayout). On r\u00e9duit donc\r\n            \/\/ leurs \u00e9chelles pour garder la hi\u00e9rarchie titre > cat. > sous-cat.\r\n            const depthMaxScale = isZoomed\r\n                ? (d.depth === 1 ? 1.18 : (d.depth === 2 ? 0.84 : 0.78))\r\n                : (d.depth === 1 ? 0.98 : (d.depth === 2 ? 0.84 : 0.74));\r\n            const depthMinScale = isZoomed\r\n                ? (d.depth === 1 ? 0.78 : (d.depth === 2 ? 0.60 : 0.64))\r\n                : (d.depth === 1 ? 0.74 : (d.depth === 2 ? 0.50 : 0.60));\r\n            const maxFs = fsBase * depthMaxScale;\r\n            const minFs = fsBase * depthMinScale;\r\n            const widthFactor = d.depth === 1 ? 0.57 : (d.depth === 2 ? 0.58 : 0.54);\r\n            const estW = longestLine * (maxFs * widthFactor);\r\n            \/\/ depth-3 (sous-cat\u00e9gories, visibles seulement en mode d\u00e9ploy\u00e9) : on\r\n            \/\/ les laisse occuper une plus grande part de leur arc \u2192 texte plus\r\n            \/\/ grand et plus lisible sur le dernier niveau.\r\n            const usableWidth = d.depth === 1 ? 0.9 : (d.depth === 2 ? 0.78 : 0.9);\r\n            if (estW <= arcLen * usableWidth) return maxFs;\r\n            return Math.max(minFs, (arcLen * usableWidth) \/ (longestLine * widthFactor));\r\n        }\r\n\r\n        function getUniformDepth2FontSize(isZoomed, useTarget = false) {\r\n            if (isCompactLayout(width)) {\r\n                if (!isZoomed) return 0;\r\n                const compactRadius = getActiveRadius(isZoomed);\r\n                const compactBase = Math.max(6.5, Math.min(11.5, compactRadius * 0.044));\r\n                return compactBase * 0.92;\r\n            }\r\n            let min = Infinity;\r\n            (typeof labelData !== \"undefined\" ? labelData : []).forEach(d => {\r\n                if (d.depth !== 2) return;\r\n                const box = useTarget ? (d.target || d.current) : (d.current || d);\r\n                if (!box || box.x1 - box.x0 < 0.0001) return;\r\n                if (useTarget && box.visible === false) return;\r\n                const fs = computeLabelFontSize(d, isZoomed, box);\r\n                if (fs < min) min = fs;\r\n            });\r\n            if (isFinite(min)) return min;\r\n            const activeRadius = getActiveRadius(isZoomed);\r\n            const sidebarOpen = document.body.classList.contains(\"sidebar-open\");\r\n            const constrained = sidebarOpen && !isCompactLayout(width);\r\n            const fsMultiplier = constrained ? 0.034 : 0.040;\r\n            const fsBaseMax = constrained ? 13 : 16;\r\n            const fsBase = Math.max(7, Math.min(fsBaseMax, activeRadius * fsMultiplier));\r\n            return isZoomed ? fsBase * 1.02 : fsBase * 0.84;\r\n        }\r\n\r\n        function invalidateUniformFontCache() {}\r\n\r\n        function effectiveLabelFontSize(d, isZoomed, box = d.current) {\r\n            if (d.depth === 2) {\r\n                const useTarget = box === d.target;\r\n                return getUniformDepth2FontSize(isZoomed, useTarget);\r\n            }\r\n            if (d.depth === 3 && isZoomed && !isCompactLayout(width)) {\r\n                \/\/ Hi\u00e9rarchie LOGIQUE quand on d\u00e9ploie un th\u00e8me : la sous-cat\u00e9gorie\r\n                \/\/ (depth-3) doit toujours \u00eatre un cran SOUS la cat\u00e9gorie (depth-2),\r\n                \/\/ jamais plus grosse. Comme les cat\u00e9gories sont rendues \u00e0 une taille\r\n                \/\/ uniforme (le min de toutes), on ancre la sous-cat\u00e9gorie \u00e0 cette\r\n                \/\/ taille et on la garde proportionnellement plus petite. On laisse\r\n                \/\/ tout de m\u00eame le calcul individuel r\u00e9duire davantage si l'arc est\r\n                \/\/ trop court (lisibilit\u00e9), via le Math.min.\r\n                const uniform2 = getUniformDepth2FontSize(isZoomed, box === d.target);\r\n                const raw = computeLabelFontSize(d, isZoomed, box);\r\n                return Math.min(raw, uniform2 * 0.82);\r\n            }\r\n            return computeLabelFontSize(d, isZoomed, box);\r\n        }\r\n\r\n        function labelStrokeWidth(d, fs) {\r\n            \/\/ Contour (halo sombre) PROPORTIONNEL \u00e0 la taille du texte. Un trait\r\n            \/\/ fixe (2.1px) sur un petit texte cr\u00e9e un contraste excessif \/ un\r\n            \/\/ effet \u00ab gras baveux \u00bb qui nuit \u00e0 la lisibilit\u00e9. On lie donc\r\n            \/\/ l'\u00e9paisseur \u00e0 la police pour garder un liser\u00e9 fin et net quel que\r\n            \/\/ soit le niveau (cat\u00e9gorie, sous-cat\u00e9gorie) ou la taille de la roue.\r\n            const base = (fs || 0) * 0.13;\r\n            if (d && d.depth === 1) return Math.max(0.5, Math.min(1.1, base));\r\n            return Math.max(0.6, Math.min(1.9, base));\r\n        }\r\n\r\n        function labelCartesianPosition(d, box = d.current) {\r\n            const angle = (box.x0 + box.x1) \/ 2;\r\n            const radiusFactor = d.depth === 1 ? 0.54 : (d.depth === 2 ? 0.52 : 0.5);\r\n            const r = box.y0 + (box.y1 - box.y0) * radiusFactor;\r\n            return polarPoint(angle, r);\r\n        }\r\n\r\n        function wrapDomainTitle(d, box = d.current, fontSize = 11.6) {\r\n            const r = (box.y0 + box.y1) \/ 2;\r\n            const arcLen = Math.max(1, (box.x1 - box.x0) * r);\r\n            const sourceLines = d.data.titleLines || [d.data.name];\r\n            const sourceText = sourceLines.join(\" \");\r\n            const maxChars = Math.max(8, Math.floor((arcLen * 0.76) \/ (fontSize * 0.56)));\r\n            const maxLines = arcLen < 150 ? 4 : 3;\r\n            const lines = [];\r\n            let current = \"\";\r\n\r\n            sourceText.split(\/\\s+\/).forEach(word => {\r\n                const next = current ? `${current} ${word}` : word;\r\n                if (next.length > maxChars && current && lines.length < maxLines - 1) {\r\n                    lines.push(current);\r\n                    current = word;\r\n                } else {\r\n                    current = next;\r\n                }\r\n            });\r\n            if (current) lines.push(current);\r\n\r\n            return lines.slice(0, maxLines);\r\n        }\r\n\r\n        function domainTitleLayout(d, box = d.current) {\r\n            \/\/ Taille proportionnelle au rayon r\u00e9el pour rester nette \u00e0 toute taille de conteneur\r\n            \/\/ (avec mode contraint quand sidebar ouverte sur desktop \/ laptop).\r\n            const isZoomed = Boolean(selectedNode);\r\n            const activeRadius = getActiveRadius(isZoomed);\r\n            const sidebarOpen = document.body.classList.contains(\"sidebar-open\");\r\n            const constrained = sidebarOpen && !isCompactLayout(width);\r\n            \/\/ Zoom\u00e9 : le titre du th\u00e8me est le FOCUS \u2192 plus gros que les autres\r\n            \/\/ niveaux (hi\u00e9rarchie titre du th\u00e8me > cat\u00e9gories > sous-cat.), sans\r\n            \/\/ \u00eatre d\u00e9mesur\u00e9. Non zoom\u00e9 : taille mod\u00e9r\u00e9e d'origine (4 titres).\r\n            const fsMultiplier = constrained ? 0.038 : (isZoomed ? 0.050 : 0.038);\r\n            const fsBaseMax = constrained ? 16 : (isZoomed ? 26 : 14);\r\n            const fsBase = Math.max(6.5, Math.min(fsBaseMax, activeRadius * fsMultiplier));\r\n\r\n            if (isCompactLayout(width)) {\r\n                const lines = (d.data.titleLines || [d.data.name]).slice(0, 2);\r\n                return {\r\n                    fontSize: fsBase * 0.92,\r\n                    lines,\r\n                    lineCount: lines.length\r\n                };\r\n            }\r\n            const initialLineCount = (d.data.titleLines || [d.data.name]).length;\r\n            const baseTitleFs = fsBase * (initialLineCount >= 3 ? 0.88 : 0.96);\r\n            const lines = wrapDomainTitle(d, box, baseTitleFs);\r\n            const adjustedFontSize = lines.length >= 4 ? baseTitleFs * 0.84 : (lines.length >= 3 ? baseTitleFs * 0.90 : baseTitleFs);\r\n            return {\r\n                fontSize: adjustedFontSize,\r\n                lines,\r\n                lineCount: lines.length\r\n            };\r\n        }\r\n\r\n        function shouldShowDomainTitle(d) {\r\n            if (!d.current.visible) return false;\r\n            if (!selectedNode) return true;\r\n            const activeDomain = selectedNode.depth === 1\r\n                ? selectedNode\r\n                : selectedNode.ancestors().find(node => node.depth === 1);\r\n            return activeDomain === d;\r\n        }\r\n\r\n        function writeMultilineSvgText(textSelection, lines, fontSize, lineHeight) {\r\n            textSelection.selectAll(\"*\").remove();\r\n            textSelection\r\n                .attr(\"text-anchor\", \"middle\")\r\n                .attr(\"dominant-baseline\", \"middle\")\r\n                .style(\"font-size\", `${fontSize}px`);\r\n\r\n            const offset = (lines.length - 1) \/ 2;\r\n            textSelection.selectAll(\"tspan\")\r\n                .data(lines)\r\n                .join(\"tspan\")\r\n                .attr(\"x\", 0)\r\n                .attr(\"y\", (line, index) => (index - offset) * lineHeight)\r\n                .text(line => line);\r\n        }\r\n\r\n        function writeCurvedSvgText(textSelection, lines, pathIdPrefix) {\r\n            textSelection.selectAll(\"*\").remove();\r\n            textSelection\r\n                .attr(\"text-anchor\", \"middle\")\r\n                .attr(\"dominant-baseline\", \"central\")\r\n                .attr(\"transform\", null);\r\n\r\n            lines.forEach((line, idx) => {\r\n                textSelection.append(\"textPath\")\r\n                    .attr(\"href\", `#${pathIdPrefix}-${idx}`)\r\n                    .attr(\"startOffset\", \"50%\")\r\n                    .text(line);\r\n            });\r\n        }\r\n\r\n        function updateLabelText(labelNode, d, fontSize) {\r\n            const lines = labelLines(d.data.name, d, d.current);\r\n            const text = d3.select(labelNode);\r\n            if (d.depth === 1) {\r\n                text.selectAll(\"*\").remove();\r\n                return;\r\n            }\r\n            \/\/ Update arc paths for each line\r\n            const idx = d.labelIndex;\r\n            const lineCount = lines.length;\r\n            for (let li = 0; li < 4; li++) {\r\n                const pathNode = document.getElementById(`tp-${idx}-${li}`);\r\n                if (!pathNode) continue;\r\n                if (li >= lineCount) {\r\n                    pathNode.setAttribute(\"d\", \"M0,0\");\r\n                } else {\r\n                    pathNode.setAttribute(\"d\", buildTextPath(\r\n                        d.current.x0, d.current.x1,\r\n                        d.current.y0, d.current.y1,\r\n                        labelLineOffset(li, lineCount, fontSize, d)\r\n                    ));\r\n                }\r\n            }\r\n            writeCurvedSvgText(text, lines, `tp-${idx}`);\r\n            labelNode.dataset.lineCount = lineCount;\r\n            labelNode.dataset.fontSize = fontSize;\r\n        }\r\n\r\n        function renderDomainTitles() {\r\n            const domainNodes = labelData.filter(d => d.depth === 1);\r\n            const titlePathData = domainNodes.flatMap(d => {\r\n                const layout = domainTitleLayout(d, d.current);\r\n                return [0, 1, 2, 3].map(lineIndex => ({ d, lineIndex, active: lineIndex < layout.lines.length }));\r\n            });\r\n\r\n            textPathsGroup.selectAll(\"path.domain-title-path\")\r\n                .data(titlePathData, item => `${item.d.labelIndex}-${item.lineIndex}`)\r\n                .join(\"path\")\r\n                .attr(\"class\", \"domain-title-path\")\r\n                .attr(\"id\", item => `dtp-${item.d.labelIndex}-${item.lineIndex}`)\r\n                .attr(\"d\", item => {\r\n                    if (!item.active) return \"M0,0\";\r\n                    const layout = domainTitleLayout(item.d, item.d.current);\r\n                    return buildTextPath(\r\n                        item.d.current.x0,\r\n                        item.d.current.x1,\r\n                        item.d.current.y0,\r\n                        item.d.current.y1,\r\n                        labelLineOffset(item.lineIndex, layout.lineCount, layout.fontSize, item.d)\r\n                    );\r\n                });\r\n\r\n            const boxes = domainTitleGroup.selectAll(\"text.domain-title-text\")\r\n                .data(domainNodes, d => d.data.name)\r\n                .join(\"text\")\r\n                .attr(\"class\", \"domain-title-text\");\r\n\r\n            boxes.each(function (d) {\r\n                const layout = domainTitleLayout(d, d.current);\r\n                const visible = shouldShowDomainTitle(d);\r\n                const text = d3.select(this);\r\n                const lines = layout.lines;\r\n                text.style(\"opacity\", visible ? 1 : 0)\r\n                    .style(\"font-size\", `${layout.fontSize}px`);\r\n\r\n                writeCurvedSvgText(text, lines, `dtp-${d.labelIndex}`);\r\n            });\r\n        }\r\n\r\n        function updateLabelPaths(d, index, fontSize) {\r\n            const labelNode = labelNodes?.[index];\r\n            if (labelNode) updateLabelText(labelNode, d, fontSize);\r\n        }\r\n\r\n        root.each(d => {\r\n            d.target = { x0: d.x0, x1: d.x1, y0: d.y0, y1: d.y1, visible: true };\r\n            d.current = { x0: d.x0, x1: d.x1, y0: d.y0, y1: d.y1, visible: true };\r\n        });\r\n\r\n        \/\/ DRAW\r\n        const labelData = root.descendants().filter(d => d.depth > 0);\r\n        labelData.forEach((d, i) => d.labelIndex = i);\r\n\r\n        const path = arcsGroup.selectAll(\"path.arc\")\r\n            .data(labelData)\r\n            .join(\"path\")\r\n            .attr(\"class\", \"arc\")\r\n            .attr(\"fill\", d => d.data.color)\r\n            .attr(\"fill-opacity\", 1)\r\n            .attr(\"d\", d => arc(d.current))\r\n            .on(\"click\", (event, d) => {\r\n                \/\/ Sous-cat\u00e9gorie (leaf) : propose la liste des assos via le prompt\r\n                if (d.depth === 3) {\r\n                    const bubble = (typeof dotData !== \"undefined\" ? dotData : []).find(b => b.subNode === d);\r\n                    if (bubble && bubble.count > 0) {\r\n                        event.stopPropagation();\r\n                        openBubbleFromWheel(bubble);\r\n                        return;\r\n                    }\r\n                }\r\n                \/\/ Domaine ou cat\u00e9gorie : zoom + prompt pour ouvrir le panneau r\u00e9cap\r\n                if (d.depth === 1 || d.depth === 2) {\r\n                    const isItem = d.depth === 2;\r\n                    const domainNode = isItem ? d.parent : d;\r\n                    showDetailPrompt({\r\n                        eyebrow: isItem ? 'Cat\u00e9gorie' : 'Domaine',\r\n                        title: d.data.name,\r\n                        color: domainNode.data.color,\r\n                        icon: '\u2197',\r\n                        action: () => openCategorySidebar(d)\r\n                    });\r\n                }\r\n                clickedArc(event, d);\r\n            })\r\n            .on(\"mouseover\", function (event, d) {\r\n                if (d.current.x1 - d.current.x0 < 0.05) {\r\n                    tooltip.style.opacity = 1;\r\n                    tooltip.innerHTML = d.data.name;\r\n                }\r\n                d3.select(this).attr(\"fill-opacity\", 1);\r\n            })\r\n            .on(\"mousemove\", function (event) {\r\n                queueTooltipPosition(event.pageX + 15, event.pageY - 15);\r\n            })\r\n            .on(\"mouseout\", function (event, d) {\r\n                tooltip.style.opacity = 0;\r\n                d3.select(this).attr(\"fill-opacity\", 1);\r\n            });\r\n\r\n        const tp = textPathsGroup.selectAll(\"path.tp\")\r\n            .data(labelData.flatMap((d, nodeIndex) => [0, 1, 2, 3].map(lineIndex => ({ d, nodeIndex, lineIndex }))))\r\n            .join(\"path\")\r\n            .attr(\"class\", \"tp\")\r\n            .attr(\"id\", item => `tp-${item.nodeIndex}-${item.lineIndex}`)\r\n            .attr(\"d\", item => {\r\n                if (item.d.depth === 1) return \"M0,0\";\r\n                const fs = effectiveLabelFontSize(item.d, false);\r\n                const lineCount = labelLines(item.d.data.name, item.d, item.d.current).length;\r\n                if (item.lineIndex >= lineCount) return \"M0,0\";\r\n                return textArcPath(item.d.current, labelLineOffset(item.lineIndex, lineCount, fs, item.d));\r\n            });\r\n\r\n        const label = textGroup.selectAll(\"text.label\")\r\n            .data(labelData)\r\n            .join(\"text\")\r\n            .attr(\"class\", d => d.depth === 1 ? \"label domain-label\" : (d.depth === 2 ? \"label item-label\" : \"label\"))\r\n            .style(\"opacity\", d => labelOpacity(d, false, d.current))\r\n            .style(\"font-size\", d => effectiveLabelFontSize(d, false) + \"px\")\r\n            .style(\"stroke-width\", d => labelStrokeWidth(d, effectiveLabelFontSize(d, false)) + \"px\")\r\n            .each(function (d) {\r\n                updateLabelText(this, d, effectiveLabelFontSize(d, false));\r\n            });\r\n        renderDomainTitles();\r\n\r\n        function polarPoint(angle, r) {\r\n            return {\r\n                x: r * Math.sin(angle),\r\n                y: -r * Math.cos(angle)\r\n            };\r\n        }\r\n\r\n        const fundingArc = d3.arc()\r\n            .startAngle(d => d.startAngle)\r\n            .endAngle(d => d.endAngle)\r\n            .innerRadius(d => d.innerRadius)\r\n            .outerRadius(d => d.outerRadius);\r\n\r\n        function associationGeometry(asso) {\r\n            const node = asso.node.current;\r\n            const angle = node.x0 + asso.angleRatio * (node.x1 - node.x0);\r\n            const isCulturesFrontieres = (asso.name || \"\").toLowerCase().includes(\"cultures et fronti\");\r\n            const tuning = associationPlacementTuning(asso.name);\r\n            const floatingAngle = angle + (asso.floatOffset || 0) + tuning.angleOffset;\r\n            const stackIndex = asso.stackIndex || 0;\r\n            const stackSize = asso.stackSize || 1;\r\n            const metrics = assoMetrics(width);\r\n            const baseGap = metrics.baseGap;\r\n            const radialGap = metrics.radialGap;\r\n            const dotR = metrics.dotR;\r\n            const centeredIndex = stackIndex - (stackSize - 1) \/ 2;\r\n            const dotRadius = node.y1\r\n                + baseGap\r\n                + stackIndex * radialGap\r\n                + Math.abs(centeredIndex) * 5\r\n                + (asso.extraRadial || 0) * metrics.extraScale\r\n                + tuning.radialBoost * metrics.extraScale\r\n                + (isCulturesFrontieres ? metrics.cultureExtra : 0);\r\n            const leaderStartRadius = node.y1 + Math.max(4, dotR * 0.55);\r\n            const leaderEndRadius = Math.max(leaderStartRadius, dotRadius - dotR - 4);\r\n            const dot = polarPoint(floatingAngle, dotRadius);\r\n            return {\r\n                anchor: polarPoint(floatingAngle, leaderStartRadius),\r\n                leaderEnd: polarPoint(floatingAngle, leaderEndRadius),\r\n                dot,\r\n                angle: floatingAngle,\r\n                outerRadius: node.y1,\r\n                keepLabelNearDot: tuning.keepNearDot\r\n            };\r\n        }\r\n\r\n        function updateAssociationMarker(markerNode, bubble) {\r\n            \/\/ v2: bubble per sub-leaf, positioned just outside the sub arc.\r\n            const sub = bubble.subNode;\r\n            const cur = sub.current || sub;\r\n            const angle = (cur.x0 + cur.x1) \/ 2;\r\n            const outerR = (cur.y1 != null ? cur.y1 : sub.y1);\r\n            const offset = isCompactLayout(width) ? 14 : 22;\r\n            const r = outerR + offset;\r\n            const x = Math.sin(angle) * r;\r\n            const y = -Math.cos(angle) * r;\r\n            markerNode.setAttribute(\"transform\", `translate(${x},${y})`);\r\n\r\n            const circle = markerNode.querySelector(\".sub-bubble-circle\");\r\n            const text = markerNode.querySelector(\".sub-bubble-count\");\r\n            const radius = bubbleRadius(bubble.count, width);\r\n            if (circle) {\r\n                circle.setAttribute(\"r\", radius);\r\n                circle.setAttribute(\"fill\", bubble.color);\r\n            }\r\n            if (text) {\r\n                text.textContent = bubble.count > 0 ? String(bubble.count) : \"\";\r\n                text.style.fontSize = bubbleFontSize(bubble.count, width) + \"px\";\r\n            }\r\n        }\r\n\r\n        function resolveAssociationLabelLayout() {\r\n            \/\/ v2: bubbles don't carry per-asso labels, so the anti-overlap pass is a no-op.\r\n            return;\r\n            if (typeof dotNodes === \"undefined\" || !selectedNode || isCompactLayout(width)) return;\r\n\r\n            const centerY = getChartCenterY(true);\r\n            const topLimit = -centerY + 8;\r\n            const bottomLimit = height - centerY - 8;\r\n            const minGap = 5;\r\n            const dotPad = (window.innerWidth >= 1400 ? 10 : 8.5) + 5;\r\n\r\n            const items = dotNodes\r\n                .filter(node => node.style.opacity !== \"0\")\r\n                .map(node => {\r\n                    const label = node.querySelector(\".association-label\");\r\n                    if (!label || label.style.display === \"none\") return null;\r\n                    const x = Number(label.dataset.labelX);\r\n                    const y = Number(label.dataset.labelY);\r\n                    const w = Number(label.dataset.labelWidth);\r\n                    const h = Number(label.dataset.labelHeight);\r\n                    if (![x, y, w, h].every(Number.isFinite)) return null;\r\n                    const anchor = label.dataset.labelAnchor || label.getAttribute(\"text-anchor\") || \"middle\";\r\n                    const xL = anchor === \"start\" ? x : (anchor === \"end\" ? x - w : x - w \/ 2);\r\n                    return {\r\n                        node,\r\n                        label,\r\n                        x,\r\n                        y,\r\n                        w,\r\n                        h,\r\n                        anchor,\r\n                        xL,\r\n                        xR: xL + w,\r\n                        dotX: Number(label.dataset.dotX),\r\n                        dotY: Number(label.dataset.dotY),\r\n                        keepNearDot: label.dataset.keepNearDot === \"true\",\r\n                        firstBaselineOffset: Number(label.dataset.firstBaselineOffset) || 0,\r\n                        side: anchor === \"start\" ? \"right\" : (anchor === \"end\" ? \"left\" : (y < 0 ? \"top\" : \"bottom\"))\r\n                    };\r\n                })\r\n                .filter(Boolean);\r\n\r\n            const placeGroup = group => {\r\n                if (group.length < 2) return;\r\n                group.sort((a, b) => a.y - b.y);\r\n\r\n                \/\/ Keep labels near their natural radial position, then only nudge enough\r\n                \/\/ to remove overlaps. Starting from the top made bottom-right quadrants\r\n                \/\/ (notably Jeunesse) drift into an unrelated vertical stack.\r\n                for (let i = 1; i < group.length; i++) {\r\n                    const prev = group[i - 1];\r\n                    const item = group[i];\r\n                    const minY = prev.y + prev.h \/ 2 + item.h \/ 2 + minGap;\r\n                    if (item.y < minY) item.y = minY;\r\n                }\r\n\r\n                const bottomOverflow = group[group.length - 1].y + group[group.length - 1].h \/ 2 - bottomLimit;\r\n                if (bottomOverflow > 0) {\r\n                    group.forEach(item => { item.y -= bottomOverflow; });\r\n                }\r\n\r\n                for (let i = group.length - 2; i >= 0; i--) {\r\n                    const next = group[i + 1];\r\n                    const item = group[i];\r\n                    const maxY = next.y - next.h \/ 2 - item.h \/ 2 - minGap;\r\n                    if (item.y > maxY) item.y = maxY;\r\n                }\r\n\r\n                const topOverflow = topLimit - (group[0].y - group[0].h \/ 2);\r\n                if (topOverflow > 0) {\r\n                    group.forEach(item => { item.y += topOverflow; });\r\n                    for (let i = 1; i < group.length; i++) {\r\n                        const prev = group[i - 1];\r\n                        const item = group[i];\r\n                        const minY = prev.y + prev.h \/ 2 + item.h \/ 2 + minGap;\r\n                        if (item.y < minY) item.y = minY;\r\n                    }\r\n                }\r\n            };\r\n\r\n            const groups = d3.group(items, item => item.side);\r\n            groups.forEach(placeGroup);\r\n\r\n            for (const item of items) {\r\n                const dotInX = Number.isFinite(item.dotX) && item.dotX >= item.xL - dotPad && item.dotX <= item.xR + dotPad;\r\n                const dotInY = Number.isFinite(item.dotY) && item.dotY >= item.y - item.h \/ 2 - dotPad && item.dotY <= item.y + item.h \/ 2 + dotPad;\r\n                if (dotInX && dotInY) {\r\n                    const preferBelow = item.dotY >= 0;\r\n                    const below = item.dotY + dotPad + item.h \/ 2;\r\n                    const above = item.dotY - dotPad - item.h \/ 2;\r\n                    if (preferBelow && below + item.h \/ 2 <= bottomLimit) item.y = below;\r\n                    else if (above - item.h \/ 2 >= topLimit) item.y = above;\r\n                    else item.y = Math.min(bottomLimit - item.h \/ 2, Math.max(topLimit + item.h \/ 2, below));\r\n                }\r\n            }\r\n\r\n            groups.forEach(placeGroup);\r\n\r\n            const overlapsItems = (a, b) => {\r\n                const horizontalGap = 4;\r\n                const verticalGap = minGap;\r\n                return a.xL - horizontalGap < b.xR\r\n                    && a.xR + horizontalGap > b.xL\r\n                    && a.y - a.h \/ 2 - verticalGap < b.y + b.h \/ 2\r\n                    && a.y + a.h \/ 2 + verticalGap > b.y - b.h \/ 2;\r\n            };\r\n            for (let pass = 0; pass < 4; pass++) {\r\n                let changed = false;\r\n                items.sort((a, b) => a.y - b.y);\r\n                for (let i = 0; i < items.length; i++) {\r\n                    for (let j = i + 1; j < items.length; j++) {\r\n                        const a = items[i];\r\n                        const b = items[j];\r\n                        if (b.y - b.h \/ 2 > a.y + a.h \/ 2 + minGap + 24) break;\r\n                        if (!overlapsItems(a, b)) continue;\r\n                        const needed = (a.h + b.h) \/ 2 + minGap;\r\n                        const current = Math.max(1, b.y - a.y);\r\n                        const push = (needed - current) \/ 2;\r\n                        a.y -= push;\r\n                        b.y += push;\r\n                        a.y = Math.max(topLimit + a.h \/ 2, Math.min(bottomLimit - a.h \/ 2, a.y));\r\n                        b.y = Math.max(topLimit + b.h \/ 2, Math.min(bottomLimit - b.h \/ 2, b.y));\r\n                        changed = true;\r\n                    }\r\n                }\r\n                if (!changed) break;\r\n            }\r\n\r\n            items.forEach(item => {\r\n                if (!item.keepNearDot || !Number.isFinite(item.dotY)) return;\r\n                const maxGap = 18;\r\n                const delta = item.y - item.dotY;\r\n                if (Math.abs(delta) > maxGap) {\r\n                    item.y = item.dotY + Math.sign(delta) * maxGap;\r\n                    item.y = Math.max(topLimit + item.h \/ 2, Math.min(bottomLimit - item.h \/ 2, item.y));\r\n                }\r\n            });\r\n\r\n            for (const item of items) {\r\n                item.label.setAttribute(\"y\", item.y + item.firstBaselineOffset);\r\n                item.label.dataset.labelY = item.y;\r\n            }\r\n        }\r\n\r\n        function renderFunding(mode = fundingMode) {\r\n            const data = fundingBandData(mode);\r\n            fundingGroup.selectAll(\"path.funding-arc\")\r\n                .data(data, d => `${d.mode}-${d.label}-${d.segmentIndex}-${d.pieceIndex}`)\r\n                .join(\r\n                    enter => enter.append(\"path\")\r\n                        .attr(\"class\", \"funding-arc\")\r\n                        .attr(\"fill\", d => d.color)\r\n                        .attr(\"opacity\", 0)\r\n                        .attr(\"d\", fundingArc)\r\n                        .on(\"mouseover\", function (event, d) {\r\n                            tooltip.style.opacity = 1;\r\n                            tooltip.innerHTML = fundingTooltip(d);\r\n                            queueTooltipPosition(event.pageX + 15, event.pageY - 15);\r\n                        })\r\n                        .on(\"mousemove\", event => queueTooltipPosition(event.pageX + 15, event.pageY - 15))\r\n                        .on(\"mouseout\", () => tooltip.style.opacity = 0),\r\n                    update => update,\r\n                    exit => exit.remove()\r\n                )\r\n                .attr(\"fill\", d => d.color)\r\n                .attr(\"d\", fundingArc)\r\n                .on(\"click\", null);\r\n\r\n            updateFundingVisibility();\r\n            renderFundingLegend();\r\n        }\r\n\r\n        function updateFundingVisibility() {\r\n            const inFundingMode = fundingMode === \"category\" && !selectedNode;\r\n            fundingGroup.style(\"pointer-events\", inFundingMode ? \"all\" : \"none\");\r\n\r\n            const ringData = fundingRingData(fundingMode);\r\n            const ringIndexByLabel = new Map(ringData.map((r, i) => [r.label, i]));\r\n\r\n            fundingGroup.selectAll(\"path.funding-arc\")\r\n                .each(function (d) {\r\n                    const groupActive = !!activeFundingCategories[d.group];\r\n                    const visible = inFundingMode && groupActive;\r\n                    const ringIdx = ringIndexByLabel.get(d.label) ?? 0;\r\n                    const sel = d3.select(this);\r\n                    sel.style(\"pointer-events\", visible ? \"all\" : \"none\");\r\n                    sel.interrupt()\r\n                        .transition()\r\n                        .duration(420)\r\n                        .delay(visible ? ringIdx * 45 : 0)\r\n                        .attr(\"opacity\", visible ? 0.95 : 0);\r\n                });\r\n        }\r\n\r\n        let dots = assosGroup.selectAll(\"g.association-marker\");\r\n        let dotNodes = [];\r\n        let dotData = [];\r\n        let dotNodeIndex = [];\r\n\r\n        function bindAssociationMarkers() {\r\n            \/\/ v2: one count bubble per sub-leaf node, instead of one marker per asso.\r\n            const subLeaves = root.descendants().filter(d => d.depth === 3);\r\n            const assosBySubNode = new Map();\r\n            associations.forEach(asso => {\r\n                if (!asso.node) return;\r\n                const arr = assosBySubNode.get(asso.node) || [];\r\n                arr.push(asso);\r\n                assosBySubNode.set(asso.node, arr);\r\n            });\r\n\r\n            dotData = subLeaves.map(subNode => {\r\n                const matches = assosBySubNode.get(subNode) || [];\r\n                return {\r\n                    subNode,\r\n                    assos: matches,\r\n                    count: matches.length,\r\n                    domainName: subNode.parent.parent.data.name,\r\n                    itemName: subNode.parent.data.name,\r\n                    subName: subNode.data.name,\r\n                    color: subNode.parent.parent.data.color\r\n                };\r\n            });\r\n\r\n            dots = assosGroup.selectAll(\"g.sub-bubble\")\r\n                .data(dotData, d => `${d.domainName}|${d.itemName}|${d.subName}`)\r\n                .join(\r\n                    enter => {\r\n                        const grp = enter.append(\"g\")\r\n                            .attr(\"class\", \"sub-bubble\")\r\n                            .style(\"opacity\", 0)\r\n                            .style(\"pointer-events\", \"none\");\r\n                        const inner = grp.append(\"g\").attr(\"class\", \"sub-bubble-inner\");\r\n                        inner.append(\"circle\").attr(\"class\", \"sub-bubble-circle\");\r\n                        inner.append(\"text\").attr(\"class\", \"sub-bubble-count\");\r\n                        return grp;\r\n                    },\r\n                    update => update,\r\n                    exit => exit.remove()\r\n                )\r\n                .each(function (d) {\r\n                    updateAssociationMarker(this, d);\r\n                })\r\n                .on(\"mouseover\", function (event, d) {\r\n                    if (!d.count) return;\r\n                    tooltip.style.opacity = 1;\r\n                    tooltip.innerHTML = `<strong>${escapeHTML(d.subName)}<\/strong><br>${d.count} association${d.count > 1 ? \"s\" : \"\"}${activeZone !== \"Toutes\" ? \" \u00b7 \" + escapeHTML(activeZone) : \"\"}`;\r\n                    queueTooltipPosition(event.pageX + 15, event.pageY - 15);\r\n                })\r\n                .on(\"mousemove\", event => queueTooltipPosition(event.pageX + 15, event.pageY - 15))\r\n                .on(\"mouseout\", () => tooltip.style.opacity = 0)\r\n                .on(\"click\", (event, d) => {\r\n                    event.stopPropagation();\r\n                    if (d.count > 0) openBubbleFromWheel(d);\r\n                });\r\n\r\n            dotNodes = dots.nodes();\r\n            dotNodeIndex = dotData.map(d => nodeIndexMap.get(d.subNode) ?? -1);\r\n        }\r\n\r\n        function bubbleRadius(count, w) {\r\n            if (!count) return 0;\r\n            const tier = screenTier(w || width, height);\r\n            return tier === \"compactPhone\" ? 11 : tier === \"phone\" ? 13 : 15;\r\n        }\r\n\r\n        function bubbleFontSize(count, w) {\r\n            const tier = screenTier(w || width, height);\r\n            const base = tier === \"compactPhone\" ? 9 : tier === \"phone\" ? 10 : 12;\r\n            if (count >= 100) return base - 2;\r\n            if (count >= 10) return base - 1;\r\n            return base;\r\n        }\r\n\r\n        const centerControl = g.append(\"g\")\r\n            .attr(\"class\", \"center-control\")\r\n            .attr(\"role\", \"button\")\r\n            .attr(\"tabindex\", \"0\")\r\n            .attr(\"aria-label\", \"Retour\")\r\n            .on(\"click\", () => {\r\n                const target = selectedNode ? (selectedNode.parent || root) : root;\r\n                clickedArc(null, target);\r\n                syncSidebarFromWheel(target);\r\n            })\r\n            .on(\"keydown\", event => {\r\n                if (event.key !== \"Enter\" && event.key !== \" \") return;\r\n                event.preventDefault();\r\n                const target = selectedNode ? (selectedNode.parent || root) : root;\r\n                clickedArc(null, target);\r\n                syncSidebarFromWheel(target);\r\n            });\r\n\r\n        centerControl.append(\"title\").text(\"Retour\");\r\n\r\n        const centerCircle = centerControl.append(\"circle\")\r\n            .attr(\"r\", radius * 0.15)\r\n            .attr(\"fill\", \"#ffffff\");\r\n\r\n        const centerText = centerControl.append(\"text\")\r\n            .attr(\"class\", \"center-text\")\r\n            .attr(\"dy\", \"0.35em\")\r\n            .text(\"\");\r\n\r\n        const centerBackIcon = centerControl.append(\"text\")\r\n            .attr(\"class\", \"center-back-icon\")\r\n            .attr(\"dy\", \"-0.15em\")\r\n            .text(\"\\u2190\");\r\n\r\n        const centerBackLabel = centerControl.append(\"text\")\r\n            .attr(\"class\", \"center-back-label\")\r\n            .attr(\"dy\", \"1.25em\")\r\n            .text(\"Retour\");\r\n\r\n        const logoDefs = svg.append(\"defs\");\r\n        const logoClipPath = logoDefs.append(\"clipPath\").attr(\"id\", \"center-logo-clip\");\r\n        const logoClipCircle = logoClipPath.append(\"circle\").attr(\"cx\", 0).attr(\"cy\", 0);\r\n\r\n        const centerLogo = centerControl.append(\"image\")\r\n            .attr(\"href\", \"https:\/\/refonte.socialhackerslab.com\/wp-content\/uploads\/2026\/04\/SHL-1-scaled.png\")\r\n            .attr(\"preserveAspectRatio\", \"xMidYMid meet\")\r\n            .attr(\"clip-path\", \"url(#center-logo-clip)\")\r\n            .attr(\"pointer-events\", \"none\");\r\n\r\n        function updateCenterControl(isZoomed, controlRadius) {\r\n            const showBack = Boolean(isZoomed);\r\n            const showLabel = showBack && controlRadius >= 30;\r\n            centerText.style(\"opacity\", showBack ? 0 : 1);\r\n            centerBackIcon\r\n                .attr(\"dy\", showLabel ? \"-0.15em\" : \"0.12em\")\r\n                .style(\"opacity\", showBack ? 1 : 0)\r\n                .style(\"font-size\", `${Math.max(18, Math.min(26, controlRadius * 0.62))}px`);\r\n            centerBackLabel\r\n                .style(\"opacity\", showLabel ? 1 : 0)\r\n                .style(\"font-size\", `${Math.max(9, Math.min(12, controlRadius * 0.28))}px`);\r\n            const logoSize = controlRadius * 1.8;\r\n            logoClipCircle.attr(\"r\", controlRadius * 0.92);\r\n            centerLogo\r\n                .attr(\"x\", -logoSize \/ 2)\r\n                .attr(\"y\", -logoSize \/ 2)\r\n                .attr(\"width\", logoSize)\r\n                .attr(\"height\", logoSize)\r\n                .style(\"opacity\", showBack ? 0 : 1);\r\n            centerControl.attr(\"aria-label\", showBack ? \"Retour\" : \"Vue d'ensemble\");\r\n            centerControl.select(\"title\").text(showBack ? \"Retour\" : \"Vue d'ensemble\");\r\n        }\r\n        updateCenterControl(false, radius * 0.15);\r\n\r\n        \/\/ Fast manual arc path builder (avoids d3.arc() recompute per frame)\r\n        const PAD_ANGLE = 0.005;\r\n        function buildArcPath(x0, x1, y0, y1) {\r\n            const span = x1 - x0;\r\n            if (!Number.isFinite(span) || !Number.isFinite(y0) || !Number.isFinite(y1)) return \"M0,0\";\r\n            if (span <= 1e-5 || span >= (2 * Math.PI - 1e-4)) return \"M0,0\";\r\n            const pa = Math.min(span \/ 2, PAD_ANGLE);\r\n            const a0 = x0 + pa \/ 2;\r\n            const a1 = x1 - pa \/ 2;\r\n            const r0 = Math.max(0, y0);\r\n            const r1 = Math.max(0, y1 - 1);\r\n            if (r1 <= r0 || a1 <= a0) return \"M0,0\";\r\n            const cos0 = Math.cos(a0 - Math.PI \/ 2), sin0 = Math.sin(a0 - Math.PI \/ 2);\r\n            const cos1 = Math.cos(a1 - Math.PI \/ 2), sin1 = Math.sin(a1 - Math.PI \/ 2);\r\n            const x0o = r1 * cos0, y0o = r1 * sin0;\r\n            const x1o = r1 * cos1, y1o = r1 * sin1;\r\n            const x0i = r0 * cos0, y0i = r0 * sin0;\r\n            const x1i = r0 * cos1, y1i = r0 * sin1;\r\n            const largeArc = (a1 - a0) > Math.PI ? 1 : 0;\r\n            return `M${x0o},${y0o}A${r1},${r1} 0 ${largeArc},1 ${x1o},${y1o}L${x1i},${y1i}A${r0},${r0} 0 ${largeArc},0 ${x0i},${y0i}Z`;\r\n        }\r\n\r\n        const animatedNodes = path.data();\r\n        const pathNodes = path.nodes();\r\n        const tpNodes = tp.nodes();\r\n        const labelNodes = label.nodes();\r\n\r\n        const nodeIndexMap = new Map(animatedNodes.map((node, index) => [node, index]));\r\n        bindAssociationMarkers();\r\n        resolveAssociationLabelLayout();\r\n\r\n        \/\/ Batch tooltip position writes\r\n        let tooltipFrame = null;\r\n        let tooltipX = 0;\r\n        let tooltipY = 0;\r\n        function queueTooltipPosition(x, y) {\r\n            tooltipX = x;\r\n            tooltipY = y;\r\n            if (tooltipFrame) return;\r\n            tooltipFrame = requestAnimationFrame(() => {\r\n                tooltip.style.left = tooltipX + 'px';\r\n                tooltip.style.top = tooltipY + 'px';\r\n                tooltipFrame = null;\r\n            });\r\n        }\r\n\r\n        function easePolyInOut(t) {\r\n            return 0.5 - Math.cos(Math.PI * t) \/ 2;\r\n        }\r\n\r\n        function nearestEquivalentAngle(angle, reference) {\r\n            const turn = 2 * Math.PI;\r\n            return angle + Math.round((reference - angle) \/ turn) * turn;\r\n        }\r\n\r\n        function zoomGlobalStart(visualStart, node) {\r\n            const domainName = node.ancestors().find(d => d.depth === 1)?.data.name || \"\";\r\n            if (domainName.includes(\"Jeunesse\")) return visualStart;\r\n            return nearestEquivalentAngle(visualStart, node.x0);\r\n        }\r\n\r\n        let currentAnimation = null;\r\n        const MIN_FRAME_MS = 1000 \/ 60;\r\n        let lastFrameTime = 0;\r\n\r\n        \/\/ Once all initial textPaths exist in the DOM, recompute them using the proper\r\n        \/\/ orientation-aware logic (the initial draw already uses buildTextPath which is\r\n        \/\/ now correct, but let's make sure everything is in sync).\r\n        invalidateUniformFontCache();\r\n        labelData.forEach((d, i) => {\r\n            const fs = effectiveLabelFontSize(d, false);\r\n            updateLabelPaths(d, i, fs);\r\n        });\r\n        renderDomainTitles();\r\n        renderFunding();\r\n\r\n        function clickedArc(event, p) {\r\n            selectedNode = p === root ? null : p;\r\n            updateBreadcrumbs(p);\r\n\r\n            const isZoomed = p !== root;\r\n            const visualStart = isZoomed ? -Math.PI \/ 2 : 0;\r\n            const globalStart = isZoomed ? zoomGlobalStart(visualStart, p) : 0;\r\n            const globalEnd = isZoomed ? globalStart + Math.PI : 2 * Math.PI;\r\n            const globalRange = globalEnd - globalStart;\r\n            const activeRadius = getActiveRadius(isZoomed);\r\n            const innerHole = activeRadius * 0.12;\r\n            const duration = 760;\r\n            g.transition()\r\n                .duration(duration)\r\n                .ease(d3.easeCubicInOut)\r\n                .attr(\"transform\", `translate(${getChartCenterX()},${getChartCenterY(isZoomed)})`);\r\n\r\n            root.each(d => {\r\n                if (!isZoomed) {\r\n                    d.target = { x0: d.x0, x1: d.x1, y0: d.y0, y1: d.y1, visible: true };\r\n                } else {\r\n                    const isDescendant = d.ancestors().includes(p);\r\n                    if (isDescendant) {\r\n                        const scale = globalRange \/ (p.x1 - p.x0);\r\n                        const yScale = (activeRadius - innerHole) \/ (radius - p.y0);\r\n                        d.target = {\r\n                            x0: globalStart + (d.x0 - p.x0) * scale,\r\n                            x1: globalStart + (d.x1 - p.x0) * scale,\r\n                            y0: innerHole + (d.y0 - p.y0) * yScale,\r\n                            y1: innerHole + (d.y1 - p.y0) * yScale,\r\n                            visible: true\r\n                        };\r\n                    } else {\r\n                        d.target = { x0: globalStart, x1: globalStart, y0: d.y0, y1: d.y1, visible: false };\r\n                    }\r\n                }\r\n            });\r\n\r\n            const animated = animatedNodes;\r\n            const starts = new Array(animated.length);\r\n            const needsMove = new Array(animated.length);\r\n            const EPS = 1e-4;\r\n            for (let i = 0; i < animated.length; i++) {\r\n                const d = animated[i];\r\n                starts[i] = { x0: d.current.x0, x1: d.current.x1, y0: d.current.y0, y1: d.current.y1 };\r\n                needsMove[i] = (\r\n                    Math.abs(d.current.x0 - d.target.x0) > EPS ||\r\n                    Math.abs(d.current.x1 - d.target.x1) > EPS ||\r\n                    Math.abs(d.current.y0 - d.target.y0) > EPS ||\r\n                    Math.abs(d.current.y1 - d.target.y1) > EPS\r\n                );\r\n            }\r\n\r\n            svg.node().style.shapeRendering = \"optimizeSpeed\";\r\n\r\n            const hideOuterDepth = isFundingSelectionActive();\r\n            for (let i = 0; i < pathNodes.length; i++) {\r\n                const d = animated[i];\r\n                const targetVisible = d.target.visible;\r\n                const hiddenForFunding = hideOuterDepth && d.depth === 3;\r\n                const vis = targetVisible && !hiddenForFunding;\r\n                pathNodes[i].style.opacity = vis ? 1 : 0;\r\n                pathNodes[i].style.pointerEvents = vis ? \"all\" : \"none\";\r\n                pathNodes[i].style.fillOpacity = 1;\r\n                if (!targetVisible) pathNodes[i].setAttribute(\"d\", \"M0,0\");\r\n            }\r\n\r\n            for (let i = 0; i < labelNodes.length; i++) {\r\n                const d = animated[i];\r\n                const fs = effectiveLabelFontSize(d, isZoomed, d.target);\r\n                labelNodes[i].style.fontSize = fs + \"px\";\r\n                labelNodes[i].style.strokeWidth = labelStrokeWidth(d, fs) + \"px\";\r\n                labelNodes[i].style.opacity = labelOpacity(d, isZoomed, d.target);\r\n                labelNodes[i].dataset.fontSize = fs;\r\n            }\r\n\r\n            textGroup.style('opacity', 0);\r\n            domainTitleGroup.style('opacity', 0);\r\n            assosGroup.style('opacity', 0);\r\n\r\n            const hideBubblesForFunding = isFundingSelectionActive();\r\n            for (let i = 0; i < dotNodes.length; i++) {\r\n                const d = dotData[i];\r\n                const vis = d.subNode.target.visible;\r\n                const show = showAssociations && vis && d.count > 0 && !hideBubblesForFunding && Boolean(selectedNode);\r\n                dotNodes[i].style.opacity = show ? 1 : 0;\r\n                dotNodes[i].style.pointerEvents = show ? \"all\" : \"none\";\r\n                \/\/ Reset the pop animation; we'll replay it when the wheel transition finishes.\r\n                const inner = dotNodes[i].querySelector(\".sub-bubble-inner\");\r\n                if (inner) {\r\n                    inner.style.transitionDelay = \"0ms\";\r\n                    inner.classList.remove(\"popped\");\r\n                }\r\n            }\r\n\r\n            centerCircle.attr(\"r\", isZoomed ? innerHole : radius * 0.22);\r\n            updateCenterControl(isZoomed, isZoomed ? innerHole : radius * 0.22);\r\n            updateFundingVisibility();\r\n\r\n            if (currentAnimation) cancelAnimationFrame(currentAnimation);\r\n\r\n            const DURATION = duration;\r\n            const startTime = performance.now();\r\n\r\n            function tick(now) {\r\n                if (now - lastFrameTime < MIN_FRAME_MS && now - startTime < DURATION) {\r\n                    currentAnimation = requestAnimationFrame(tick);\r\n                    return;\r\n                }\r\n                lastFrameTime = now;\r\n\r\n                const elapsed = now - startTime;\r\n                const progress = Math.min(elapsed \/ DURATION, 1);\r\n                const k = easePolyInOut(progress);\r\n\r\n                for (let i = 0; i < animated.length; i++) {\r\n                    if (!needsMove[i]) continue;\r\n                    const d = animated[i];\r\n                    const s = starts[i];\r\n                    const t = d.target;\r\n                    const x0 = s.x0 + (t.x0 - s.x0) * k;\r\n                    const x1 = s.x1 + (t.x1 - s.x1) * k;\r\n                    const y0 = s.y0 + (t.y0 - s.y0) * k;\r\n                    const y1 = s.y1 + (t.y1 - s.y1) * k;\r\n                    d.current.x0 = x0;\r\n                    d.current.x1 = x1;\r\n                    d.current.y0 = y0;\r\n                    d.current.y1 = y1;\r\n                    if (!d.target.visible) {\r\n                        d.current.visible = false;\r\n                        pathNodes[i].setAttribute(\"d\", \"M0,0\");\r\n                        continue;\r\n                    }\r\n                    d.current.visible = true;\r\n                    pathNodes[i].setAttribute(\"d\", buildArcPath(x0, x1, y0, y1));\r\n                }\r\n\r\n                if (progress < 1) {\r\n                    currentAnimation = requestAnimationFrame(tick);\r\n                } else {\r\n                    currentAnimation = null;\r\n                    svg.node().style.shapeRendering = \"geometricPrecision\";\r\n                    for (let i = 0; i < animated.length; i++) {\r\n                        if (!needsMove[i]) continue;\r\n                        const d = animated[i];\r\n                        if (!d.target.visible) {\r\n                            d.current.x0 = d.target.x0;\r\n                            d.current.x1 = d.target.x1;\r\n                            d.current.y0 = d.target.y0;\r\n                            d.current.y1 = d.target.y1;\r\n                            d.current.visible = false;\r\n                            pathNodes[i].setAttribute(\"d\", \"M0,0\");\r\n                            continue;\r\n                        }\r\n                        d.current.x0 = d.target.x0;\r\n                        d.current.x1 = d.target.x1;\r\n                        d.current.y0 = d.target.y0;\r\n                        d.current.y1 = d.target.y1;\r\n                        d.current.visible = true;\r\n                        pathNodes[i].setAttribute(\"d\", buildArcPath(d.target.x0, d.target.x1, d.target.y0, d.target.y1));\r\n                    }\r\n                    for (let i = 0; i < animated.length; i++) {\r\n                        updateLabelPaths(animated[i], i, Number(labelNodes[i].dataset.fontSize || 13));\r\n                    }\r\n                    for (let i = 0; i < dotNodes.length; i++) {\r\n                        updateAssociationMarker(dotNodes[i], dotData[i]);\r\n                    }\r\n                    textGroup.style('opacity', 1);\r\n                    resolveAssociationLabelLayout();\r\n                    renderDomainTitles();\r\n                    domainTitleGroup.style('opacity', 1);\r\n                    assosGroup.style('opacity', 1);\r\n                    \/\/ Cascade pop-in: order visible bubbles by angular position, then stagger.\r\n                    const visibleBubbles = [];\r\n                    for (let i = 0; i < dotNodes.length; i++) {\r\n                        if (dotNodes[i].style.opacity === \"1\") {\r\n                            const sub = dotData[i].subNode;\r\n                            const angle = (sub.target.x0 + sub.target.x1) \/ 2;\r\n                            visibleBubbles.push({ node: dotNodes[i], angle });\r\n                        }\r\n                    }\r\n                    visibleBubbles.sort((a, b) => a.angle - b.angle);\r\n                    visibleBubbles.forEach((b, rank) => {\r\n                        const inner = b.node.querySelector(\".sub-bubble-inner\");\r\n                        if (!inner) return;\r\n                        inner.style.transitionDelay = (rank * 35) + \"ms\";\r\n                        inner.classList.add(\"popped\");\r\n                    });\r\n                }\r\n            }\r\n\r\n            currentAnimation = requestAnimationFrame(tick);\r\n        }\r\n\r\n        \/\/ Applique imm\u00e9diatement la visibilit\u00e9 des bulles d'associations selon\r\n        \/\/ l'\u00e9tat showAssociations, sans rejouer l'animation de zoom de la roue.\r\n        function refreshBubbleVisibility() {\r\n            if (!dotNodes.length) return;\r\n            const hideBubblesForFunding = isFundingSelectionActive();\r\n            const visibleBubbles = [];\r\n            for (let i = 0; i < dotNodes.length; i++) {\r\n                const d = dotData[i];\r\n                const cur = d.subNode.current || d.subNode;\r\n                const subVisible = cur.visible !== false;\r\n                const show = showAssociations && subVisible && d.count > 0\r\n                    && !hideBubblesForFunding && Boolean(selectedNode);\r\n                dotNodes[i].style.opacity = show ? 1 : 0;\r\n                dotNodes[i].style.pointerEvents = show ? \"all\" : \"none\";\r\n                const inner = dotNodes[i].querySelector(\".sub-bubble-inner\");\r\n                if (!inner) continue;\r\n                if (show) {\r\n                    updateAssociationMarker(dotNodes[i], d);\r\n                    visibleBubbles.push({ inner, angle: (cur.x0 + cur.x1) \/ 2 });\r\n                } else {\r\n                    inner.style.transitionDelay = \"0ms\";\r\n                    inner.classList.remove(\"popped\");\r\n                }\r\n            }\r\n            \/\/ Pop-in en cascade, ordonn\u00e9 par position angulaire.\r\n            visibleBubbles.sort((a, b) => a.angle - b.angle);\r\n            visibleBubbles.forEach((b, rank) => {\r\n                b.inner.style.transitionDelay = (rank * 35) + \"ms\";\r\n                b.inner.classList.add(\"popped\");\r\n            });\r\n        }\r\n\r\n        function redrawForCurrentSize() {\r\n            const size = getChartSize();\r\n            width = size.width;\r\n            height = size.height;\r\n            radius = getResponsiveRadius(width, height);\r\n\r\n            svg.attr(\"viewBox\", `0 0 ${width} ${height}`);\r\n            g.interrupt().attr(\"transform\", `translate(${getChartCenterX()},${getChartCenterY(Boolean(selectedNode))})`);\r\n\r\n            partition.size([2 * Math.PI, radius])(root);\r\n            applyRadialLayout(root, radius);\r\n            const activeNode = selectedNode || root;\r\n            root.each(d => {\r\n                d.current = { x0: d.x0, x1: d.x1, y0: d.y0, y1: d.y1, visible: true };\r\n                d.target = { ...d.current };\r\n            });\r\n            prepareAssociations(associations);\r\n\r\n            pathNodes.forEach((node, i) => {\r\n                const d = animatedNodes[i];\r\n                node.setAttribute(\"d\", buildArcPath(d.current.x0, d.current.x1, d.current.y0, d.current.y1));\r\n            });\r\n            animatedNodes.forEach((d, i) => {\r\n                const fs = effectiveLabelFontSize(d, Boolean(selectedNode), d.current);\r\n                labelNodes[i].style.fontSize = fs + \"px\";\r\n                labelNodes[i].style.strokeWidth = labelStrokeWidth(d, fs) + \"px\";\r\n                labelNodes[i].style.opacity = labelOpacity(d, Boolean(selectedNode), d.current);\r\n                labelNodes[i].dataset.fontSize = fs;\r\n                updateLabelPaths(d, i, fs);\r\n            });\r\n            renderDomainTitles();\r\n            bindAssociationMarkers();\r\n            dotNodes.forEach((node, i) => {\r\n                const d = dotData[i];\r\n                const dot = node.querySelector(\".association-dot\");\r\n                if (dot) dot.setAttribute(\"r\", assoMetrics(width).dotR);\r\n                updateAssociationMarker(node, d);\r\n            });\r\n            resolveAssociationLabelLayout();\r\n            renderFunding();\r\n\r\n            centerCircle.attr(\"r\", getActiveRadius(Boolean(selectedNode)) * 0.12);\r\n            updateCenterControl(Boolean(selectedNode), getActiveRadius(Boolean(selectedNode)) * 0.12);\r\n            clickedArc(null, activeNode);\r\n        }\r\n\r\n        const breadcrumbsEl = document.getElementById('breadcrumbs');\r\n        function updateBreadcrumbs(p) {\r\n            if (!breadcrumbsEl) return;\r\n            if (!p || p.depth === 0) {\r\n                breadcrumbsEl.innerHTML = '<span class=\"active\">Tous les domaines<\/span>';\r\n                return;\r\n            }\r\n            let pathNodes = p.ancestors().reverse();\r\n            let html = '';\r\n            pathNodes.forEach((node, i) => {\r\n                const isLast = i === pathNodes.length - 1;\r\n                const className = isLast ? 'active' : '';\r\n                const safeName = node.data.name.replace(\/'\/g, \"\\\\'\");\r\n                html += `<span class=\"${className}\" onclick=\"window.navToDepth(${node.depth}, '${safeName}')\">${node.data.name}<\/span>`;\r\n                if (!isLast) html += '<span class=\"sep\">\u203a<\/span>';\r\n            });\r\n            breadcrumbsEl.innerHTML = html;\r\n        }\r\n        updateBreadcrumbs(root);\r\n\r\n        window.navToDepth = (depth, name) => {\r\n            const target = root.descendants().find(d => d.depth === depth && d.data.name === name);\r\n            if (target) {\r\n                clickedArc(null, target);\r\n                syncSidebarFromWheel(target);\r\n            }\r\n        };\r\n\r\n        function syncSidebarFromWheel(targetNode) {\r\n            \/\/ Synchronise la sidebar avec la navigation directe sur la roue.\r\n            \/\/ Ne d\u00e9clenche jamais une ouverture (respect du flow par prompt) :\r\n            \/\/ - si la sidebar est ferm\u00e9e, on ne fait que tenter de re-proposer le prompt\r\n            \/\/ - si la sidebar est ouverte, on actualise le contenu pour matcher la roue\r\n            const isOpen = document.body.classList.contains('sidebar-open');\r\n            if (!targetNode || !targetNode.data || targetNode === root || targetNode.depth === 0) {\r\n                if (isOpen) {\r\n                    setSidebarOpen(false);\r\n                    sidebarStack = [];\r\n                    updateSidebarBackButton();\r\n                    hideDetailPrompt(true);\r\n                }\r\n                return;\r\n            }\r\n            if (!isOpen) return;\r\n            if (targetNode.depth === 1 || targetNode.depth === 2) {\r\n                \/\/ Remplace l'\u00e9tat courant pour ne pas empiler \u00e0 l'infini lors d'un d\u00e9zoom\r\n                openCategorySidebar(targetNode, { replaceTop: true });\r\n            }\r\n        }\r\n\r\n        const sidebar = document.getElementById('sidebar');\r\n        const sidebarContent = document.getElementById('sidebar-content');\r\n        const closeBtn = document.getElementById('close-sidebar');\r\n        const zoneFilters = document.getElementById('zone-filters');\r\n        const zoneSelect = document.getElementById('zone-select');\r\n        const zonePickerButton = document.getElementById('zone-picker-button');\r\n        const zonePickerCurrent = document.getElementById('zone-picker-current');\r\n        const zonePickerOptions = document.getElementById('zone-picker-options');\r\n        const fundingToggle = document.getElementById('funding-toggle');\r\n        const detailPromptEl       = document.getElementById('detail-prompt');\r\n        const detailPromptIconEl   = document.getElementById('detail-prompt-icon');\r\n        const detailPromptEyebrowEl = document.getElementById('detail-prompt-eyebrow');\r\n        const detailPromptTitleEl  = document.getElementById('detail-prompt-title');\r\n        const detailPromptCloseEl  = document.getElementById('detail-prompt-close');\r\n        const sidebarBackBtn       = document.getElementById('sidebar-back-btn');\r\n        let detailPromptAction = null;\r\n        let detailPromptTimer  = null;\r\n        let lastPromptContext  = null;  \/\/ pour re-proposer apr\u00e8s fermeture\r\n        let sidebarStack = [];          \/\/ pile d'\u00e9tats sidebar (pour Retour)\r\n        let sidebarLayoutRAF = null;\r\n\r\n        function hideDetailPrompt(clearContext) {\r\n            if (!detailPromptEl) return;\r\n            detailPromptEl.classList.remove('visible');\r\n            detailPromptEl.setAttribute('aria-hidden', 'true');\r\n            detailPromptAction = null;\r\n            if (clearContext) lastPromptContext = null;\r\n            if (detailPromptTimer) { clearTimeout(detailPromptTimer); detailPromptTimer = null; }\r\n        }\r\n        function paintDetailPrompt(ctx) {\r\n            detailPromptEl.style.setProperty('--prompt-color', ctx.color || 'var(--accent-color)');\r\n            detailPromptEyebrowEl.textContent = ctx.eyebrow || '';\r\n            detailPromptTitleEl.textContent = ctx.title || '';\r\n            detailPromptTitleEl.title = ctx.title || '';\r\n            detailPromptIconEl.textContent = ctx.icon || '\u2197';\r\n            detailPromptAction = ctx.action || null;\r\n            detailPromptEl.classList.add('visible');\r\n            detailPromptEl.setAttribute('aria-hidden', 'false');\r\n        }\r\n        function showDetailPrompt(ctx) {\r\n            if (!detailPromptEl) { if (ctx && ctx.action) ctx.action(); return; }\r\n            \/\/ Le prompt s'affiche TOUJOURS (m\u00eame si la sidebar est ouverte) \u2014 l'utilisateur\r\n            \/\/ d\u00e9cide quand basculer vers le nouveau contenu.\r\n            lastPromptContext = ctx;\r\n            paintDetailPrompt(ctx);\r\n            if (detailPromptTimer) clearTimeout(detailPromptTimer);\r\n            detailPromptTimer = setTimeout(() => hideDetailPrompt(false), 7000);\r\n        }\r\n        \/\/ Re-propose le dernier \u00e9l\u00e9ment s\u00e9lectionn\u00e9 apr\u00e8s fermeture de la sidebar\r\n        function replayDetailPromptIfAny() {\r\n            if (!detailPromptEl || !lastPromptContext) return;\r\n            paintDetailPrompt(lastPromptContext);\r\n            if (detailPromptTimer) clearTimeout(detailPromptTimer);\r\n            \/\/ Pas d'auto-dismiss au replay : on laisse le bouton dispo\r\n        }\r\n\r\n        function updateSidebarBackButton() {\r\n            if (!sidebarBackBtn) return;\r\n            const canGoBack = sidebarStack.length > 0;\r\n            sidebarBackBtn.hidden = !canGoBack;\r\n        }\r\n        function closeSidebarToWheel() {\r\n            selectedAsso = null;\r\n            lastSubBubble = null;\r\n            sidebarStack = [];\r\n            updateSidebarBackButton();\r\n            setSidebarOpen(false);\r\n            hideDetailPrompt(true);\r\n            clickedArc(null, root);\r\n        }\r\n        function navigateSidebarBack() {\r\n            if (sidebarStack.length <= 1) {\r\n                closeSidebarToWheel();\r\n                return;\r\n            }\r\n            sidebarStack.pop();             \/\/ retire l'\u00e9tat courant\r\n            const previous = sidebarStack.pop(); \/\/ r\u00e9cup\u00e8re le pr\u00e9c\u00e9dent (sera r\u00e9-pouss\u00e9 par open)\r\n            if (!previous) { closeSidebarToWheel(); return; }\r\n            applySidebarState(previous);\r\n        }\r\n        function applySidebarState(state) {\r\n            if (!state) return;\r\n            if (state.kind === 'category') openCategorySidebar(state.node, { fromHistory: true });\r\n            else if (state.kind === 'sublist') openSubListSidebar(state.bubble, { fromHistory: true });\r\n            else if (state.kind === 'asso') openSidebar(state.asso, state.originBubble, { fromHistory: true });\r\n        }\r\n        function pushSidebarState(state, opts) {\r\n            \/\/ Si on n'est pas en train de remonter via Retour, on empile\r\n            if (opts && opts.fromHistory) {\r\n                sidebarStack.push(state);\r\n            } else if (opts && opts.replaceTop) {\r\n                if (sidebarStack.length) sidebarStack.pop();\r\n                sidebarStack.push(state);\r\n            } else {\r\n                sidebarStack.push(state);\r\n            }\r\n            updateSidebarBackButton();\r\n        }\r\n        if (sidebarBackBtn) {\r\n            sidebarBackBtn.addEventListener('click', event => {\r\n                event.stopPropagation();\r\n                navigateSidebarBack();\r\n            });\r\n        }\r\n        if (detailPromptEl) {\r\n            detailPromptEl.addEventListener('click', event => {\r\n                if (event.target && event.target.id === 'detail-prompt-close') return;\r\n                if (detailPromptAction) {\r\n                    const action = detailPromptAction;\r\n                    hideDetailPrompt();\r\n                    action();\r\n                }\r\n            });\r\n        }\r\n        if (detailPromptCloseEl) {\r\n            detailPromptCloseEl.addEventListener('click', event => {\r\n                event.stopPropagation();\r\n                hideDetailPrompt();\r\n            });\r\n        }\r\n        function computeFundingTotals(assos) {\r\n            let requested = 0, requestedCount = 0;\r\n            let obtained = 0, obtainedCount = 0;\r\n            assos.forEach(a => {\r\n                let hasRequested = false;\r\n                let hasObtained = false;\r\n                const rows = Array.isArray(a.breakdown) && a.breakdown.length\r\n                    ? a.breakdown\r\n                    : [getRequestedAndObtainedForRow(a)];\r\n                rows.forEach(row => {\r\n                    \/\/ Les autres ann\u00e9es sont affich\u00e9es mais pas compt\u00e9es.\r\n                    if (!isCountedYear(row.year)) return;\r\n                    const r = Number(row.requested || 0);\r\n                    const o = Number(row.obtained || 0);\r\n                    if (r > 0) {\r\n                        requested += r;\r\n                        hasRequested = true;\r\n                    }\r\n                    if (o > 0) {\r\n                        obtained += o;\r\n                        hasObtained = true;\r\n                    }\r\n                });\r\n                if (hasRequested) requestedCount += 1;\r\n                if (hasObtained) obtainedCount += 1;\r\n            });\r\n            return { requested, requestedCount, obtained, obtainedCount };\r\n        }\r\n\r\n        function activeFundingCalculationRows() {\r\n            return activeZone === \"Toutes\"\r\n                ? fundingCalculationRows\r\n                : fundingCalculationRows.filter(row => normalizeZone(row.zone) === activeZone);\r\n        }\r\n\r\n        function computeFundingTotalsForContext(rows, matchFn) {\r\n            let requested = 0, obtained = 0;\r\n            const reqAssos = new Set();\r\n            const obtAssos = new Set();\r\n            rows.forEach((row, idx) => {\r\n                if (!matchFn(row)) return;\r\n                if (!isCountedYear(row.year)) return; \/\/ autres ann\u00e9es : non compt\u00e9es\r\n                const r = Number(row.requested || 0);\r\n                const o = Number(row.obtained || 0);\r\n                const assoKey = row.associationKey || row.name || `row-${idx}`;\r\n                if (r > 0) {\r\n                    requested += r;\r\n                    reqAssos.add(assoKey);\r\n                }\r\n                if (o > 0) {\r\n                    obtained += o;\r\n                    obtAssos.add(assoKey);\r\n                }\r\n            });\r\n            return { requested, requestedCount: reqAssos.size, obtained, obtainedCount: obtAssos.size };\r\n        }\r\n\r\n        function buildContextMatchForCategoryNode(node, isItem) {\r\n            const cn = canonicalLookupValue;\r\n            if (isItem) {\r\n                const domainName = cn(node.parent.data.name);\r\n                const itemName = cn(node.data.name);\r\n                return row => cn(row.domain) === domainName && cn(row.item) === itemName;\r\n            }\r\n            const domainName = cn(node.data.name);\r\n            return row => cn(row.domain) === domainName;\r\n        }\r\n\r\n        function buildContextMatchForBubble(bubble) {\r\n            const cn = canonicalLookupValue;\r\n            const domainName = cn(bubble.domainName);\r\n            const itemName = cn(bubble.itemName);\r\n            const subName = cn(bubble.subName);\r\n            return row =>\r\n                cn(row.domain) === domainName &&\r\n                cn(row.item) === itemName &&\r\n                cn(row.sub) === subName;\r\n        }\r\n\r\n        function renderFundingTotalsHTML(totals, totalAssos) {\r\n            \/\/ Toujours afficher les 2 sections, avec un tiret si vide.\r\n            \/\/ Cela \u00e9vite l'ambigu\u00eft\u00e9 \"rien affich\u00e9 = 0 ou bug ?\".\r\n            const reqValue = totals.requested > 0 ? formatTotalRaised(totals.requested) : \"\u2014\";\r\n            const reqSub = totals.requested > 0\r\n                ? \"Montants des financements sollicit\u00e9s (toutes ann\u00e9es)\"\r\n                : \"Aucun montant sollicit\u00e9 renseign\u00e9\";\r\n            const obtValue = totals.obtained > 0 ? formatTotalRaised(totals.obtained) : \"\u2014\";\r\n            const obtSub = totals.obtained > 0\r\n                ? \"Montants des financements obtenus (toutes ann\u00e9es)\"\r\n                : \"Aucun montant obtenu renseign\u00e9\";\r\n            return `\r\n                <div class=\"sub-total-raised sub-total-raised--requested${totals.requested === 0 ? ' is-empty' : ''}\">\r\n                    <div>\r\n                        <div class=\"label\">Total sollicit\u00e9<\/div>\r\n                        <div class=\"sub\">${reqSub}<\/div>\r\n                    <\/div>\r\n                    <div class=\"value\">${reqValue}<\/div>\r\n                <\/div>\r\n                <div class=\"sub-total-raised${totals.obtained === 0 ? ' is-empty' : ''}\">\r\n                    <div>\r\n                        <div class=\"label\">Total obtenu<\/div>\r\n                        <div class=\"sub\">${obtSub}<\/div>\r\n                    <\/div>\r\n                    <div class=\"value\">${obtValue}<\/div>\r\n                <\/div>\r\n            `;\r\n        }\r\n\r\n        function scheduleSidebarLayoutRedraw() {\r\n            if (sidebarLayoutRAF) cancelAnimationFrame(sidebarLayoutRAF);\r\n            sidebarLayoutRAF = requestAnimationFrame(() => {\r\n                sidebarLayoutRAF = null;\r\n                redrawForCurrentSize();\r\n            });\r\n        }\r\n\r\n        function setSidebarOpen(isOpen) {\r\n            const wasOpen = document.body.classList.contains('sidebar-open');\r\n            sidebar.classList.toggle('hidden', !isOpen);\r\n            document.body.classList.toggle('sidebar-open', isOpen);\r\n            if (wasOpen !== isOpen) scheduleSidebarLayoutRedraw();\r\n            if (isOpen && typeof hideDetailPrompt === 'function') hideDetailPrompt();\r\n        }\r\n\r\n        function setSidebarContent(html) {\r\n            sidebarContent.innerHTML = html;\r\n            sidebarContent.scrollTop = 0;\r\n            if (window.matchMedia && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\r\n            sidebarContent.classList.remove(\"is-entering\");\r\n            void sidebarContent.offsetWidth;\r\n            sidebarContent.classList.add(\"is-entering\");\r\n        }\r\n\r\n        \/\/ Le lecteur YouTube exige que la page embarquante lui envoie un referrer\r\n        \/\/ valide, sinon il refuse de d\u00e9marrer (\u00ab Error 153 \u00bb). Deux cas posent\r\n        \/\/ probl\u00e8me : la page ouverte en local (file:\/\/, aucun referrer possible)\r\n        \/\/ et une politique de referrer restrictive c\u00f4t\u00e9 site.\r\n        function canEmbedYouTubePlayer() {\r\n            return location.protocol === \"http:\" || location.protocol === \"https:\";\r\n        }\r\n\r\n        \/\/ Au clic sur la miniature, on remplace la fa\u00e7ade par le lecteur YouTube\r\n        \/\/ (domaine nocookie, lecture imm\u00e9diate). Rien n'est charg\u00e9 avant ce clic.\r\n        function bindProfileVideo() {\r\n            const trigger = sidebarContent.querySelector(\".profile-video\");\r\n            if (!trigger) return;\r\n            trigger.addEventListener(\"click\", () => {\r\n                const id = trigger.getAttribute(\"data-video-id\");\r\n                if (!id) return;\r\n                const watchUrl = trigger.getAttribute(\"data-video-watch\")\r\n                    || `https:\/\/www.youtube.com\/watch?v=${id}`;\r\n                \/\/ Aper\u00e7u local : l'iframe afficherait une erreur de configuration,\r\n                \/\/ on ouvre donc directement YouTube dans un nouvel onglet.\r\n                if (!canEmbedYouTubePlayer()) {\r\n                    window.open(watchUrl, \"_blank\", \"noopener\");\r\n                    return;\r\n                }\r\n                const start = parseInt(trigger.getAttribute(\"data-video-start\") || \"0\", 10);\r\n                const params = `autoplay=1&rel=0&modestbranding=1&playsinline=1`\r\n                    + `${start > 0 ? `&start=${start}` : \"\"}`\r\n                    + `&origin=${encodeURIComponent(location.origin)}`;\r\n                const frame = document.createElement(\"iframe\");\r\n                frame.className = \"profile-video-frame\";\r\n                frame.src = `https:\/\/www.youtube-nocookie.com\/embed\/${id}?${params}`;\r\n                frame.title = trigger.getAttribute(\"aria-label\") || \"Vid\u00e9o de pr\u00e9sentation\";\r\n                frame.allow = \"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\";\r\n                frame.setAttribute(\"allowfullscreen\", \"\");\r\n                \/\/ \u00ab origin \u00bb (et non la valeur par d\u00e9faut) : garantit l'envoi du\r\n                \/\/ referrer m\u00eame si la page parente impose no-referrer.\r\n                frame.setAttribute(\"referrerpolicy\", \"origin\");\r\n                \/\/ On remplace le <button> par un conteneur neutre : une iframe\r\n                \/\/ imbriqu\u00e9e dans un bouton ne re\u00e7oit pas correctement les clics.\r\n                const player = document.createElement(\"div\");\r\n                player.className = \"profile-video is-playing\";\r\n                player.setAttribute(\"style\", trigger.getAttribute(\"style\") || \"\");\r\n                player.style.cursor = \"default\";\r\n                player.appendChild(frame);\r\n                trigger.replaceWith(player);\r\n            });\r\n        }\r\n\r\n        const zoneFilterDefinitions = [\r\n            {\r\n                label: \"Biarritz\",\r\n                aliases: [\"biarritz\", \"biaritz\"]\r\n            },\r\n            {\r\n                label: \"Outre-mer\",\r\n                aliases: [\r\n                    \"outre mer\",\r\n                    \"outremer\",\r\n                    \"guadeloupe\",\r\n                    \"martinique\",\r\n                    \"guyane\",\r\n                    \"mayotte\",\r\n                    \"reunion\",\r\n                    \"la reunion\",\r\n                    \"nouvelle caledonie\",\r\n                    \"polynesie\",\r\n                    \"wallis\",\r\n                    \"saint martin\",\r\n                    \"saint pierre\"\r\n                ]\r\n            },\r\n            {\r\n                label: \"M\u00e9tropole Aix-Marseille\",\r\n                aliases: [\r\n                    \"metropole aix marseille\",\r\n                    \"metropole d aix marseille\",\r\n                    \"aix marseille\",\r\n                    \"aix en provence\",\r\n                    \"marseille\",\r\n                    \"port de bouc\",\r\n                    \"port bouc\",\r\n                    \"istres\",\r\n                    \"martigues\"\r\n                ]\r\n            },\r\n            {\r\n                label: \"Paris\",\r\n                aliases: [\r\n                    \"paris\",\r\n                    \"ile de france\",\r\n                    \"ile-de-france\",\r\n                    \"idf\",\r\n                    \"region parisienne\",\r\n                    \"r\u00e9gion parisienne\",\r\n                    \"grand paris\"\r\n                ]\r\n            }\r\n        ];\r\n\r\n        function normalizeZone(zone) {\r\n            const key = canonicalLookupValue(zone);\r\n            const match = zoneFilterDefinitions.find(def =>\r\n                def.aliases.some(alias => key.includes(canonicalLookupValue(alias)))\r\n            );\r\n            return match ? match.label : \"Sans zone\";\r\n        }\r\n\r\n        function escapeHTML(value) {\r\n            return String(value ?? \"\").replace(\/[&<>\"']\/g, char => ({\r\n                \"&\": \"&amp;\",\r\n                \"<\": \"&lt;\",\r\n                \">\": \"&gt;\",\r\n                '\"': \"&quot;\",\r\n                \"'\": \"&#039;\"\r\n            }[char]));\r\n        }\r\n\r\n        function safeWebsiteUrl(value) {\r\n            const raw = String(value || \"\").trim();\r\n            if (!raw) return \"\";\r\n            if (\/^https?:\\\/\\\/\/i.test(raw)) return safeExternalUrl(raw);\r\n            if (\/\\s\/.test(raw) || !raw.includes(\".\")) return \"\";\r\n            return safeExternalUrl(`https:\/\/${raw}`);\r\n        }\r\n\r\n        function safeExternalUrl(value) {\r\n            const raw = String(value || \"\").trim();\r\n            if (!raw) return \"\";\r\n            if (!\/^https?:\\\/\\\/\/i.test(raw)) return \"\";\r\n            try {\r\n                return new URL(raw).href;\r\n            } catch (_) {\r\n                return \"\";\r\n            }\r\n        }\r\n\r\n        function splitLinkValues(value) {\r\n            const raw = String(value || \"\").trim();\r\n            if (!raw) return [];\r\n            return raw\r\n                .split(\/[\\n\\r,;]+|\\s+(?=https?:\\\/\\\/)\/i)\r\n                .map(part => safeExternalUrl(part))\r\n                .filter(Boolean);\r\n        }\r\n\r\n        \/\/ Reconna\u00eet les formats YouTube utilis\u00e9s par l'\u00e9quipe : youtu.be\/ID,\r\n        \/\/ watch?v=ID, \/shorts\/ID, \/embed\/ID (avec ou sans param\u00e8tres ?si=\u2026).\r\n        function parseYouTubeVideo(value) {\r\n            const url = safeExternalUrl(String(value || \"\").trim());\r\n            if (!url) return null;\r\n            let parsed;\r\n            try { parsed = new URL(url); }\r\n            catch (_) { return null; }\r\n            const host = parsed.hostname.replace(\/^www\\.\/i, \"\").toLowerCase();\r\n            const isYouTube = host === \"youtu.be\"\r\n                || host === \"youtube.com\"\r\n                || host === \"m.youtube.com\"\r\n                || host === \"youtube-nocookie.com\";\r\n            if (!isYouTube) return null;\r\n\r\n            let id = \"\";\r\n            let isShort = false;\r\n            if (host === \"youtu.be\") {\r\n                id = parsed.pathname.split(\"\/\").filter(Boolean)[0] || \"\";\r\n            } else if (parsed.pathname === \"\/watch\") {\r\n                id = parsed.searchParams.get(\"v\") || \"\";\r\n            } else {\r\n                const parts = parsed.pathname.split(\"\/\").filter(Boolean);\r\n                if (parts[0] === \"shorts\" || parts[0] === \"embed\" || parts[0] === \"live\") {\r\n                    id = parts[1] || \"\";\r\n                    isShort = parts[0] === \"shorts\";\r\n                }\r\n            }\r\n            if (!\/^[A-Za-z0-9_-]{6,20}$\/.test(id)) return null;\r\n\r\n            const start = parseInt(parsed.searchParams.get(\"t\") || \"\", 10);\r\n            return {\r\n                id,\r\n                isShort,\r\n                start: isFinite(start) && start > 0 ? start : 0,\r\n                watchUrl: isShort\r\n                    ? `https:\/\/www.youtube.com\/shorts\/${id}`\r\n                    : `https:\/\/www.youtube.com\/watch?v=${id}`,\r\n                thumbUrl: `https:\/\/i.ytimg.com\/vi\/${id}\/hqdefault.jpg`\r\n            };\r\n        }\r\n\r\n        function safeEmailHref(value) {\r\n            const email = String(value || \"\").trim().replace(\/[\\r\\n]\/g, \"\");\r\n            if (!email) return \"\";\r\n            return `mailto:${encodeURIComponent(email)}`;\r\n        }\r\n\r\n        function safePhoneHref(value) {\r\n            const phone = String(value || \"\").trim().replace(\/[\\r\\n]\/g, \"\");\r\n            if (!phone) return \"\";\r\n            const compact = phone.replace(\/[^\\d+]\/g, \"\");\r\n            return compact ? `tel:${compact}` : \"\";\r\n        }\r\n\r\n        function detailRowsHTML(rows) {\r\n            const html = rows\r\n                .filter(([, value]) => String(value ?? \"\").trim())\r\n                .map(([label, value]) => `\r\n                    <div class=\"detail-row\">\r\n                        <div class=\"detail-label\">${escapeHTML(label)}<\/div>\r\n                        <div class=\"detail-value\">${escapeHTML(value)}<\/div>\r\n                    <\/div>\r\n                `)\r\n                .join(\"\");\r\n            return html ? `<div class=\"asso-detail-grid\">${html}<\/div>` : \"\";\r\n        }\r\n\r\n        function summaryRowsHTML(rows) {\r\n            const html = rows\r\n                .filter(([, value]) => String(value ?? \"\").trim())\r\n                .map(([label, value]) => `\r\n                    <div class=\"summary-row\">\r\n                        <div class=\"summary-label\">${escapeHTML(label)}<\/div>\r\n                        <div class=\"summary-value\">${escapeHTML(value)}<\/div>\r\n                    <\/div>\r\n                `)\r\n                .join(\"\");\r\n            return html ? `<div class=\"asso-summary\">${html}<\/div>` : \"\";\r\n        }\r\n\r\n        function statusPillHTML(value) {\r\n            const label = String(value || \"\").trim();\r\n            if (!label) return \"\";\r\n            const isPositive = canonicalLookupValue(label) === \"oui\";\r\n            return `<span class=\"status-pill${isPositive ? \"\" : \" is-muted\"}\">${escapeHTML(label)}<\/span>`;\r\n        }\r\n\r\n        function formatYesNo(value) {\r\n            const key = normalizeLookupValue(value);\r\n            if (!key) return \"\";\r\n            if (key === \"oui\" || key.startsWith(\"oui\")) return \"Oui\";\r\n            if (key === \"non\" || key.startsWith(\"non\")) return \"Non\";\r\n            return String(value || \"\").trim();\r\n        }\r\n\r\n        \/\/ \u00ab Financement d\u00e9pos\u00e9 \u00bb : quand la r\u00e9ponse est \u00ab Non \u00bb, on GARDE\r\n        \/\/ l'explication de la feuille (ex. \u00ab Non (se pr\u00e9pare au prochain\r\n        \/\/ appel) \u00bb), pour savoir POURQUOI la structure n'a pas d\u00e9pos\u00e9, au lieu\r\n        \/\/ de tout r\u00e9duire \u00e0 Oui\/Non.\r\n        function formatFinancingFiled(value) {\r\n            const raw = String(value || \"\").trim();\r\n            const key = normalizeLookupValue(raw);\r\n            if (!key) return \"\";\r\n            if (key === \"oui\" || key.startsWith(\"oui\")) return \"Oui\";\r\n            return raw; \/\/ \u00ab Non (\u2026) \u00bb conserv\u00e9 tel quel\r\n        }\r\n\r\n        function formatRaisedAmount(value) {\r\n            const raw = String(value || \"\").trim();\r\n            if (!raw) return \"\";\r\n            const key = normalizeLookupValue(raw);\r\n            const nonAmountTerms = [\r\n                \"non applicable\",\r\n                \"non renseigne\",\r\n                \"non renseignee\",\r\n                \"financement non depose\",\r\n                \"non depose\",\r\n                \"pas de financement\",\r\n                \"aucun financement\",\r\n                \"sans objet\",\r\n                \"neant\",\r\n                \"n a\",\r\n                \"na\"\r\n            ];\r\n            if (\r\n                key === \"oui\" ||\r\n                key === \"non\" ||\r\n                nonAmountTerms.some(term => key.includes(normalizeLookupValue(term)))\r\n            ) {\r\n                return raw;\r\n            }\r\n            const withoutCurrency = raw\r\n                .replace(\/[\u20ac$\u00a3]\/g, \"\")\r\n                .replace(\/\\b(eur|euros?)\\b\/gi, \"\")\r\n                .trim();\r\n            const compact = withoutCurrency.replace(\/[\\s\\u00A0]\/g, \"\");\r\n            const looksLikeAmount = \/^[-+]?\\d+(?:[.,]\\d+)?$\/i.test(compact)\r\n                || \/^[-+]?\\d{1,3}(?:[.,]\\d{3})+(?:[.,]\\d+)?$\/i.test(compact);\r\n            if (!looksLikeAmount) return raw;\r\n            if (\/[\u20ac$\u00a3]\/.test(raw) || \/\\b(eur|euros?)\\b\/i.test(raw)) return raw;\r\n            return `${raw} \u20ac`;\r\n        }\r\n\r\n        function parseAmountToNumber(value) {\r\n            const raw = String(value || \"\").trim();\r\n            if (!raw) return 0;\r\n            const key = normalizeLookupValue(raw);\r\n            const nonAmountTerms = [\"non applicable\",\"non renseigne\",\"non renseignee\",\"financement non depose\",\"non depose\",\"pas de financement\",\"aucun financement\",\"sans objet\",\"neant\",\"n a\",\"na\"];\r\n            if (key === \"oui\" || key === \"non\" || nonAmountTerms.some(term => key.includes(normalizeLookupValue(term)))) return 0;\r\n            const compact = raw\r\n                .replace(\/[\u20ac$\u00a3]\/g, \"\")\r\n                .replace(\/\\b(eur|euros?)\\b\/gi, \"\")\r\n                .replace(\/[\\s\u00a0\u202f]\/g, \"\")\r\n                .trim();\r\n            \/\/ Si format \"60 000,50\" : virgule = d\u00e9cimal, points = milliers \u2192 on enl\u00e8ve les points\r\n            \/\/ Si format \"60,000.50\" : virgule = milliers \u2192 on enl\u00e8ve les virgules\r\n            let normalized = compact;\r\n            const hasDot = normalized.includes(\".\");\r\n            const hasComma = normalized.includes(\",\");\r\n            if (hasDot && hasComma) {\r\n                if (normalized.lastIndexOf(\",\") > normalized.lastIndexOf(\".\")) {\r\n                    normalized = normalized.replace(\/\\.\/g, \"\").replace(\",\", \".\");\r\n                } else {\r\n                    normalized = normalized.replace(\/,\/g, \"\");\r\n                }\r\n            } else if (hasComma) {\r\n                \/\/ virgule unique \u2192 consid\u00e9r\u00e9 comme d\u00e9cimal sauf si plusieurs (alors milliers)\r\n                const commaCount = (normalized.match(\/,\/g) || []).length;\r\n                normalized = commaCount > 1 ? normalized.replace(\/,\/g, \"\") : normalized.replace(\",\", \".\");\r\n            }\r\n            const n = parseFloat(normalized.replace(\/[^\\d.-]\/g, \"\"));\r\n            return isFinite(n) ? n : 0;\r\n        }\r\n\r\n        function formatTotalRaised(n) {\r\n            if (!n) return \"\";\r\n            try { return new Intl.NumberFormat(\"fr-FR\").format(Math.round(n)) + \"\u00a0\u20ac\"; }\r\n            catch (_) { return Math.round(n) + \"\u00a0\u20ac\"; }\r\n        }\r\n\r\n        function rowKeyMatchesAlias(rowKey, aliasKey) {\r\n            if (!rowKey || !aliasKey) return false;\r\n            if (rowKey === aliasKey || rowKey.includes(aliasKey)) return true;\r\n            const rowTokens = new Set(rowKey.split(\/\\s+\/));\r\n            return aliasKey.split(\/\\s+\/).every(token => rowTokens.has(token));\r\n        }\r\n\r\n        function readRowValue(row, aliases) {\r\n            const entries = Object.entries(row).map(([key, value]) => ({\r\n                key: normalizeLookupValue(key),\r\n                value: String(value ?? \"\").trim()\r\n            }));\r\n            for (const alias of aliases) {\r\n                const aliasKey = normalizeLookupValue(alias);\r\n                const match = entries.find(entry => entry.value && rowKeyMatchesAlias(entry.key, aliasKey));\r\n                if (match) return match.value;\r\n            }\r\n            return \"\";\r\n        }\r\n\r\n        \/\/ Variante \u00e0 correspondance EXACTE de l'en-t\u00eate (libell\u00e9 normalis\u00e9\r\n        \/\/ identique). Indispensable pour distinguer la colonne \u00ab Montant \u00bb\r\n        \/\/ (le montant OBTENU) de \u00ab Montant sollicit\u00e9 \u00bb : la correspondance\r\n        \/\/ partielle de readRowValue ferait sinon pointer \u00ab montant \u00bb vers\r\n        \/\/ \u00ab montant sollicit\u00e9 \u00bb, et l'obtenu ne serait jamais lu.\r\n        function readRowValueExact(row, aliases) {\r\n            const entries = Object.entries(row).map(([key, value]) => ({\r\n                key: normalizeLookupValue(key),\r\n                value: String(value ?? \"\").trim()\r\n            }));\r\n            for (const alias of aliases) {\r\n                const aliasKey = normalizeLookupValue(alias);\r\n                const match = entries.find(entry => entry.value && entry.key === aliasKey);\r\n                if (match) return match.value;\r\n            }\r\n            return \"\";\r\n        }\r\n\r\n        function spreadsheetColumnLetter(index) {\r\n            let n = index + 1;\r\n            let out = \"\";\r\n            while (n > 0) {\r\n                n -= 1;\r\n                out = String.fromCharCode(65 + (n % 26)) + out;\r\n                n = Math.floor(n \/ 26);\r\n            }\r\n            return out;\r\n        }\r\n\r\n        function readColumnValue(row, zeroBasedIndex) {\r\n            const letter = spreadsheetColumnLetter(zeroBasedIndex);\r\n            const candidates = [\r\n                `__col_${letter}`,\r\n                `__col_${zeroBasedIndex + 1}`,\r\n                letter,\r\n                `Colonne ${zeroBasedIndex + 1}`\r\n            ];\r\n            for (const key of candidates) {\r\n                const value = String(row?.[key] ?? \"\").trim();\r\n                if (value) return value;\r\n            }\r\n            return \"\";\r\n        }\r\n\r\n        function parseYearFromValue(value) {\r\n            if (value == null) return null;\r\n            if (value instanceof Date) return value.getFullYear();\r\n            const str = String(value).trim();\r\n            if (!str) return null;\r\n            const match = str.match(\/(19|20)\\d{2}\/);\r\n            return match ? parseInt(match[0], 10) : null;\r\n        }\r\n\r\n        function normalizeImportedAssociation(row, index) {\r\n            const rawDomain = readRowValue(row, [\"domaine principal\", \"domaine\", \"categorie principale\", \"categorie\"]);\r\n            const rawItem = readRowValue(row, [\"thematique\", \"theme\", \"axe\", \"sous categorie\"]);\r\n            const rawSub = readRowValue(row, [\"sous thematique\", \"sous theme\", \"sous categorie detaillee\"]);\r\n            return normalizeAssociationTaxonomy({\r\n                id: readRowValue(row, [\"id\", \"identifiant\"]) || `sheet-${index + 1}`,\r\n                \/\/ L'ann\u00e9e est dans la 1re colonne (A), dont l'en-t\u00eate est vide :\r\n                \/\/ on la lit par position \u00e0 d\u00e9faut d'un en-t\u00eate \u00ab ann\u00e9e \u00bb.\r\n                year: parseYearFromValue(readRowValue(row, [\"horodateur\", \"annee\", \"ann\u00e9e\", \"date\", \"exercice\"]) || readColumnValue(row, 0)),\r\n                name: readRowValue(row, [\"nom association\", \"nom de votre association\", \"association\", \"nom\"]),\r\n                logo: readRowValue(row, [\"logo\", \"reference\", \"fiche\", \"page\"]),\r\n                contactName: readRowValue(row, [\"contact nom\", \"nom contact\", \"personne contact\", \"referent\", \"r\u00e9f\u00e9rent\"]),\r\n                zone: normalizeZone(readRowValue(row, [\"ville zone\", \"zone\", \"ville\", \"territoire\", \"antenne\"])),\r\n                email: readRowValue(row, [\"email de contact\", \"email association\", \"mail association\", \"email\", \"mail\"]),\r\n                phone: readRowValue(row, [\"telephone\", \"t\u00e9l\u00e9phone\", \"tel\", \"mobile\"]),\r\n                publicTarget: readRowValue(row, [\"public cible\", \"public\", \"beneficiaires\", \"b\u00e9n\u00e9ficiaires\"]),\r\n                desc: readRowValue(row, [\"description projet association\", \"description projet\", \"description\", \"presentation\"]) || \"Description non renseign\u00e9e.\",\r\n                financingFiled: readRowValue(row, [\"depot projet europeen\", \"d\u00e9p\u00f4t projet europ\u00e9en\", \"depot de projet europeen\", \"d\u00e9p\u00f4t de projet europ\u00e9en\", \"financement depose\", \"financement d\u00e9pos\u00e9\", \"financement\", \"dossier depose\", \"dossier d\u00e9pos\u00e9\"]),\r\n                financingType: readRowValue(row, [\"type de financement\", \"type financement\", \"programme financement\", \"programme\", \"dispositif\"]),\r\n                partnerCountry: readRowValue(row, [\"pays partenaire\", \"pays partenaires\", \"pays\", \"partenaire\", \"partenaires\"]),\r\n                amountRequested: readRowValue(row, [\"montant sollicite\", \"montant sollicit\u00e9\", \"financements sollicites\", \"financements sollicit\u00e9s\", \"financement sollicite\", \"financement sollicit\u00e9\", \"montant demande\", \"montant demand\u00e9\"]),\r\n                \/\/ Obtenu = colonne \u00ab Montant \u00bb (P), lue en EXACT pour ne pas\r\n                \/\/ \u00eatre capt\u00e9e par \u00ab Montant sollicit\u00e9 \u00bb (O). On tente d'abord un\r\n                \/\/ vrai libell\u00e9 \u00ab montant obtenu \u00bb s'il existe un jour.\r\n                amountObtained: readRowValue(row, [\"montant obtenu\", \"montants obtenus\", \"financements obtenus\", \"financement obtenu\", \"montant leve\", \"montant lev\u00e9\", \"montant leve par l asso\", \"montant lev\u00e9 par l asso\"]) || readRowValueExact(row, [\"montant\"]),\r\n                amount: readRowValueExact(row, [\"montant\"]) || readRowValue(row, [\"montant leve\", \"montant lev\u00e9\", \"montant leve par l asso\", \"montant lev\u00e9 par l asso\", \"budget\"]),\r\n                metropoleContact: readRowValue(row, [\"interlocuteur metropole\", \"interlocuteur m\u00e9tropole\", \"contact metropole\", \"contact m\u00e9tropole\"]),\r\n                domain: rawDomain,\r\n                item: rawItem,\r\n                sub: rawSub,\r\n                rawDomain,\r\n                rawItem,\r\n                rawSub,\r\n                website: readRowValue(row, [\"site web\", \"site internet\", \"site\", \"website\", \"url site\", \"url du site\"]),\r\n                video: readRowValue(row, [\"video\", \"vid\u00e9o\", \"lien video\", \"lien vid\u00e9o\", \"video presentation\", \"vid\u00e9o de pr\u00e9sentation\", \"video de l association\", \"lien youtube\", \"youtube\"]),\r\n                \/\/ Les fiches Erasmus+ sont coll\u00e9es dans \u00ab D\u00e9tail du projet \u00bb (comme la carte\r\n                \/\/ partenaire). splitLinkValues ne gardera que les URL, le texte est ignor\u00e9.\r\n                projectLinks: readRowValue(row, [\"liens projets europeens\", \"liens projets europ\u00e9ens\", \"lien projet europeen\", \"lien projet europ\u00e9en\", \"lien erasmus\", \"liens erasmus\", \"erasmus link\", \"project link\", \"project links\", \"detail du projet\", \"d\u00e9tail du projet\", \"detail\", \"d\u00e9tail\"])\r\n            });\r\n        }\r\n\r\n        function toCsvUrl(rawUrl) {\r\n            const value = String(rawUrl || \"\").trim();\r\n            if (!value) return \"\";\r\n            if (value.includes(\"docs.google.com\/forms\/\")) return value;\r\n            if (!value.includes(\"docs.google.com\/spreadsheets\/\")) return value;\r\n            const gidMatch = value.match(\/[?#&]gid=(\\d+)\/);\r\n            const gid = gidMatch ? gidMatch[1] : \"0\";\r\n            const sheetMatch = value.match(\/\\\/spreadsheets\\\/d\\\/([^\/]+)\/);\r\n            if (sheetMatch && value.includes(\"\/edit\")) {\r\n                return `https:\/\/docs.google.com\/spreadsheets\/d\/${sheetMatch[1]}\/export?format=csv&gid=${gid}`;\r\n            }\r\n            const published = value.replace(\"\/pubhtml\", \"\/pub\");\r\n            if (\/[?&](output|format)=csv\\b\/i.test(published)) return published;\r\n            return `${published}${published.includes(\"?\") ? \"&\" : \"?\"}output=csv`;\r\n        }\r\n\r\n        function isGoogleSheetUrl(rawUrl) {\r\n            return String(rawUrl || \"\").includes(\"docs.google.com\/spreadsheets\/\");\r\n        }\r\n\r\n        function isPublishedSheetUrl(rawUrl) {\r\n            return \/\\\/spreadsheets\\\/d\\\/e\\\/2PACX-\/i.test(String(rawUrl || \"\"));\r\n        }\r\n\r\n        function toGoogleVizUrl(rawUrl, callbackName) {\r\n            const value = String(rawUrl || \"\").trim();\r\n            const publishedMatch = value.match(\/\\\/spreadsheets\\\/d\\\/e\\\/([^\/]+)\/);\r\n            const privateMatch = value.match(\/\\\/spreadsheets\\\/d\\\/([^\/]+)\/);\r\n            const id = publishedMatch?.[1] || privateMatch?.[1];\r\n            if (!id) return \"\";\r\n            const base = publishedMatch\r\n                ? `https:\/\/docs.google.com\/spreadsheets\/d\/e\/${id}\/gviz\/tq`\r\n                : `https:\/\/docs.google.com\/spreadsheets\/d\/${id}\/gviz\/tq`;\r\n            const gidMatch = value.match(\/[?#&]gid=(\\d+)\/);\r\n            const params = [\r\n                `tqx=${encodeURIComponent(`out:json;responseHandler:${callbackName}`)}`,\r\n                \"headers=1\",\r\n                `_=${Date.now()}`\r\n            ];\r\n            if (gidMatch) params.push(`gid=${encodeURIComponent(gidMatch[1])}`);\r\n            return `${base}?${params.join(\"&\")}`;\r\n        }\r\n\r\n        function googleVizResponseToRows(response) {\r\n            if (!response || response.status !== \"ok\") {\r\n                throw new Error(response?.errors?.[0]?.detailed_message || \"Google Sheet non lisible.\");\r\n            }\r\n            const table = response.table || {};\r\n            const headers = (table.cols || []).map((col, index) => col.label || col.id || `Colonne ${index + 1}`);\r\n            return (table.rows || []).map(row => {\r\n                const out = {};\r\n                headers.forEach((header, index) => {\r\n                    const cell = row.c?.[index];\r\n                    const value = cell ? (cell.f ?? cell.v ?? \"\") : \"\";\r\n                    out[header] = value;\r\n                    out[`__col_${index + 1}`] = value;\r\n                    out[`__col_${spreadsheetColumnLetter(index)}`] = value;\r\n                });\r\n                return out;\r\n            });\r\n        }\r\n\r\n        function loadGoogleSheetRows(rawUrl) {\r\n            return new Promise((resolve, reject) => {\r\n                const callbackName = `__sheetCallback_${Date.now()}_${Math.floor(Math.random() * 100000)}`;\r\n                const script = document.createElement(\"script\");\r\n                const cleanup = () => {\r\n                    delete window[callbackName];\r\n                    script.remove();\r\n                };\r\n                const timeout = window.setTimeout(() => {\r\n                    cleanup();\r\n                    reject(new Error(\"Timeout pendant le chargement Google Sheet.\"));\r\n                }, 15000);\r\n                window[callbackName] = response => {\r\n                    window.clearTimeout(timeout);\r\n                    try {\r\n                        resolve(googleVizResponseToRows(response));\r\n                    } catch (error) {\r\n                        reject(error);\r\n                    } finally {\r\n                        cleanup();\r\n                    }\r\n                };\r\n                script.onerror = () => {\r\n                    window.clearTimeout(timeout);\r\n                    cleanup();\r\n                    reject(new Error(\"Impossible de charger le script Google Sheet.\"));\r\n                };\r\n                script.src = toGoogleVizUrl(rawUrl, callbackName);\r\n                document.head.appendChild(script);\r\n            });\r\n        }\r\n\r\n        function loadAssociationRows(rawUrl) {\r\n            if (isPublishedSheetUrl(rawUrl)) {\r\n                return Promise.reject(new Error(\"Ce lien publie en 2PACX ne peut pas etre charge depuis un fichier local. Collez le lien normal de la Sheet en \/spreadsheets\/d\/...\/edit, avec partage lecteur.\"));\r\n            }\r\n            if (isGoogleSheetUrl(rawUrl)) return loadGoogleSheetRows(rawUrl);\r\n            const csvUrl = toCsvUrl(rawUrl);\r\n            return d3.csv(`${csvUrl}${csvUrl.includes(\"?\") ? \"&\" : \"?\"}_=${Date.now()}`);\r\n        }\r\n\r\n        function availableZones() {\r\n            return zoneFilterDefinitions.map(def => def.label);\r\n        }\r\n\r\n        function renderZoneSelect() {\r\n            if (!zoneFilters || !zoneSelect) return;\r\n            const zones = availableZones();\r\n            zoneFilters.hidden = zones.length === 0;\r\n            const zoneValues = [\"Toutes\", ...zones];\r\n\r\n            zoneSelect.innerHTML = \"\";\r\n            zoneValues.forEach(zone => {\r\n                const option = document.createElement(\"option\");\r\n                option.value = zone;\r\n                option.textContent = zone === \"Toutes\" ? \"Toutes les zones\" : zone;\r\n                option.selected = zone === activeZone;\r\n                zoneSelect.appendChild(option);\r\n            });\r\n\r\n            if (zonePickerCurrent) {\r\n                zonePickerCurrent.textContent = activeZone === \"Toutes\" ? \"Toutes les zones\" : activeZone;\r\n            }\r\n\r\n            if (zonePickerOptions) {\r\n                zonePickerOptions.innerHTML = \"\";\r\n                zoneValues.forEach(zone => {\r\n                    const button = document.createElement(\"button\");\r\n                    button.type = \"button\";\r\n                    button.className = \"zone-picker-option\" + (zone === activeZone ? \" active\" : \"\");\r\n                    button.dataset.zone = zone;\r\n                    button.textContent = zone === \"Toutes\" ? \"Toutes les zones\" : zone;\r\n                    button.addEventListener(\"click\", () => {\r\n                        activeZone = zone;\r\n                        zoneSelect.value = zone;\r\n                        closeZonePicker();\r\n                        renderZoneSelect();\r\n                        applyZoneFilter();\r\n                    });\r\n                    zonePickerOptions.appendChild(button);\r\n                });\r\n            }\r\n        }\r\n\r\n        function closeZonePicker() {\r\n            if (!zonePickerOptions || !zonePickerButton) return;\r\n            zonePickerOptions.hidden = true;\r\n            zonePickerButton.setAttribute(\"aria-expanded\", \"false\");\r\n        }\r\n\r\n        function toggleZonePicker() {\r\n            if (!zonePickerOptions || !zonePickerButton) return;\r\n            const willOpen = zonePickerOptions.hidden;\r\n            zonePickerOptions.hidden = !willOpen;\r\n            zonePickerButton.setAttribute(\"aria-expanded\", willOpen ? \"true\" : \"false\");\r\n        }\r\n\r\n        if (zonePickerButton) {\r\n            zonePickerButton.addEventListener(\"click\", event => {\r\n                event.stopPropagation();\r\n                toggleZonePicker();\r\n            });\r\n        }\r\n\r\n        if (zonePickerOptions) {\r\n            zonePickerOptions.addEventListener(\"click\", event => event.stopPropagation());\r\n        }\r\n\r\n        document.addEventListener(\"click\", closeZonePicker);\r\n        document.addEventListener(\"keydown\", event => {\r\n            if (event.key === \"Escape\") closeZonePicker();\r\n        });\r\n\r\n        function applyZoneFilter() {\r\n            associations = activeZone === \"Toutes\"\r\n                ? allAssociations.slice()\r\n                : allAssociations.filter(asso => normalizeZone(asso.zone) === activeZone);\r\n            prepareAssociations(associations);\r\n            bindAssociationMarkers();\r\n            \/\/ If the bubble list sidebar is open, refresh it against the new bubble data.\r\n            if (lastSubBubble && !selectedAsso && !sidebar.classList.contains('hidden')) {\r\n                const refreshed = (typeof dotData !== \"undefined\" ? dotData : []).find(d =>\r\n                    d.domainName === lastSubBubble.domainName &&\r\n                    d.itemName === lastSubBubble.itemName &&\r\n                    d.subName === lastSubBubble.subName\r\n                );\r\n                if (refreshed) {\r\n                    if (refreshed.count > 0) openSubListSidebar(refreshed);\r\n                    else { setSidebarOpen(false); lastSubBubble = null; }\r\n                }\r\n            }\r\n            clickedArc(null, selectedNode || root);\r\n        }\r\n\r\n        function setAssociationSource(source, calculationRows) {\r\n            allAssociations = source.map((asso, index) => ({\r\n                ...normalizeAssociationTaxonomy(asso),\r\n                id: asso.id || `asso-${index + 1}`,\r\n                zone: normalizeZone(asso.zone)\r\n            }));\r\n            fundingCalculationRows = Array.isArray(calculationRows)\r\n                ? calculationRows\r\n                : buildFallbackFundingCalculationRows(allAssociations);\r\n            const zones = availableZones();\r\n            if (activeZone !== \"Toutes\" && !zones.includes(activeZone)) activeZone = \"Toutes\";\r\n            renderZoneSelect();\r\n            applyZoneFilter();\r\n        }\r\n\r\n        function restoreLocalAssociationsFallback() {\r\n            if (allAssociations.length || !fallbackAssociations.length) return;\r\n            setAssociationSource(fallbackAssociations.map(asso => ({ ...asso })));\r\n        }\r\n\r\n        \/\/ Plusieurs lignes du Sheet peuvent d\u00e9crire la m\u00eame association sur des ann\u00e9es\r\n        \/\/ diff\u00e9rentes (un dossier par exercice). On fusionne ces doublons en UNE fiche :\r\n        \/\/   - Montant class\u00e9 en sollicit\u00e9 pour 2026 et en obtenu pour les autres ann\u00e9es\r\n        \/\/   - taxonomie\/contact pris sur la ligne la plus r\u00e9cente (fallback sur les autres)\r\n        \/\/   - pays partenaires \/ types de financement fusionn\u00e9s (union)\r\n        function getFundingReferenceYear() {\r\n            return fundingReferenceYear;\r\n        }\r\n\r\n        \/\/ D\u00e9cision Sonia (29\/07\/2026) : les totaux (par th\u00e9matique et globaux)\r\n        \/\/ additionnent TOUTES les ann\u00e9es \u2014 2026 ET les ann\u00e9es ant\u00e9rieures \u2014 au\r\n        \/\/ sollicit\u00e9 comme \u00e0 l'obtenu. On conserve cette fonction comme point de\r\n        \/\/ r\u00e9glage si l'on voulait re-filtrer une ann\u00e9e un jour.\r\n        function isCountedYear(year) {\r\n            return true;\r\n        }\r\n\r\n        \/\/ Sollicit\u00e9 = colonne \u00ab Montant sollicit\u00e9 \u00bb (O) ; obtenu = colonne\r\n        \/\/ \u00ab Montant \u00bb (P). Pas de reclassement par ann\u00e9e ici : le filtrage se\r\n        \/\/ fait dans les totaux (cf. isCountedYear), pour que les montants des\r\n        \/\/ autres ann\u00e9es restent visibles sans \u00eatre compt\u00e9s.\r\n        function getRequestedAndObtainedForRow(asso) {\r\n            const explicitReq = parseAmountToNumber(asso.amountRequested);\r\n            const explicitObt = parseAmountToNumber(asso.amountObtained);\r\n            const amt = parseAmountToNumber(asso.amount);\r\n            const requested = explicitReq > 0 ? explicitReq : 0;\r\n            let obtained = explicitObt > 0 ? explicitObt : 0;\r\n            \/\/ Un \u00ab Montant \u00bb g\u00e9n\u00e9rique sans sollicit\u00e9\/obtenu explicite = obtenu.\r\n            if (requested <= 0 && obtained <= 0 && amt > 0) obtained = amt;\r\n            return { requested, obtained };\r\n        }\r\n\r\n        function findCanonicalDomainName(value) {\r\n            const key = canonicalLookupValue(value);\r\n            if (!key) return \"\";\r\n            const match = root.children.find(d => canonicalLookupValue(d.data.name) === key);\r\n            return match ? match.data.name : String(value || \"\").trim();\r\n        }\r\n\r\n        function findCanonicalItemName(value, domainName) {\r\n            const key = canonicalLookupValue(value);\r\n            if (!key) return \"\";\r\n            const domainKey = canonicalLookupValue(domainName);\r\n            const domains = root.children.filter(d => !domainKey || canonicalLookupValue(d.data.name) === domainKey);\r\n            const items = domains.flatMap(d => d.children || []);\r\n            const match = items.find(d => canonicalLookupValue(d.data.name) === key);\r\n            return match ? match.data.name : \"\";\r\n        }\r\n\r\n        function findCanonicalSubNode(value, domainName, itemName) {\r\n            const key = canonicalLookupValue(value);\r\n            if (!key) return null;\r\n            const domainKey = canonicalLookupValue(domainName);\r\n            const itemKey = canonicalLookupValue(itemName);\r\n            const leaves = root.descendants().filter(d => {\r\n                if (d.depth !== 3) return false;\r\n                if (domainKey && canonicalLookupValue(d.parent.parent.data.name) !== domainKey) return false;\r\n                if (itemKey && canonicalLookupValue(d.parent.data.name) !== itemKey) return false;\r\n                return true;\r\n            });\r\n            return leaves.find(d => canonicalLookupValue(d.data.name) === key) || null;\r\n        }\r\n\r\n        function normalizeFundingCalculationTaxonomy(asso) {\r\n            const rawDomain = String(asso.rawDomain || asso.domain || \"\").trim();\r\n            const rawItem = String(asso.rawItem || \"\").trim();\r\n            const rawSub = String(asso.rawSub || \"\").trim();\r\n            const domain = findCanonicalDomainName(rawDomain || asso.domain);\r\n            let item = rawItem ? findCanonicalItemName(rawItem, domain) : \"\";\r\n            let sub = \"\";\r\n\r\n            if (rawSub) {\r\n                const subNode = findCanonicalSubNode(rawSub, domain, item);\r\n                if (subNode) {\r\n                    sub = subNode.data.name;\r\n                    item = item || subNode.parent.data.name;\r\n                }\r\n            }\r\n\r\n            return { domain, item, sub };\r\n        }\r\n\r\n        function buildFundingCalculationRows(imports) {\r\n            if (!Array.isArray(imports)) return [];\r\n            return imports.map((asso, index) => {\r\n                const split = getRequestedAndObtainedForRow(asso);\r\n                if (split.requested <= 0 && split.obtained <= 0) return null;\r\n                const taxonomy = normalizeFundingCalculationTaxonomy(asso);\r\n                if (!taxonomy.domain) return null;\r\n                return {\r\n                    associationKey: canonicalLookupValue(asso.name) || `row-${index}`,\r\n                    name: asso.name || \"\",\r\n                    zone: normalizeZone(asso.zone),\r\n                    year: asso.year == null ? null : asso.year,\r\n                    domain: taxonomy.domain,\r\n                    item: taxonomy.item,\r\n                    sub: taxonomy.sub,\r\n                    requested: split.requested,\r\n                    obtained: split.obtained\r\n                };\r\n            }).filter(Boolean);\r\n        }\r\n\r\n        function buildFallbackFundingCalculationRows(source) {\r\n            return source.map((asso, index) => {\r\n                const normalized = normalizeAssociationTaxonomy(asso);\r\n                const split = getRequestedAndObtainedForRow(normalized);\r\n                return {\r\n                    associationKey: canonicalLookupValue(normalized.name) || `fallback-${index}`,\r\n                    name: normalized.name || \"\",\r\n                    zone: normalizeZone(normalized.zone),\r\n                    year: normalized.year == null ? null : normalized.year,\r\n                    domain: normalized.domain || \"\",\r\n                    item: normalized.item || \"\",\r\n                    sub: normalized.sub || \"\",\r\n                    requested: split.requested,\r\n                    obtained: split.obtained\r\n                };\r\n            }).filter(row => row.domain && (row.requested > 0 || row.obtained > 0));\r\n        }\r\n\r\n        function mergeAssociationDuplicatesByName(imports) {\r\n            if (!Array.isArray(imports) || imports.length === 0) return imports;\r\n            const groups = new Map();\r\n            imports.forEach((asso, index) => {\r\n                const key = canonicalLookupValue(asso.name) || `__row_${index}`;\r\n                if (!groups.has(key)) groups.set(key, []);\r\n                groups.get(key).push(asso);\r\n            });\r\n\r\n            const merged = [];\r\n            groups.forEach(group => {\r\n                const sorted = group.slice().sort((a, b) => {\r\n                    const ya = (a.year == null ? -Infinity : a.year);\r\n                    const yb = (b.year == null ? -Infinity : b.year);\r\n                    return yb - ya;\r\n                });\r\n                const base = sorted[0];\r\n                const pickFirst = field => {\r\n                    for (const r of sorted) {\r\n                        const v = String(r[field] == null ? \"\" : r[field]).trim();\r\n                        if (v) return v;\r\n                    }\r\n                    return \"\";\r\n                };\r\n                const pickLongest = field => {\r\n                    let best = \"\";\r\n                    for (const r of sorted) {\r\n                        const v = String(r[field] == null ? \"\" : r[field]).trim();\r\n                        if (v.length > best.length) best = v;\r\n                    }\r\n                    return best;\r\n                };\r\n                const unionList = (field, separator) => {\r\n                    const seen = new Set();\r\n                    const out = [];\r\n                    sorted.forEach(r => {\r\n                        String(r[field] == null ? \"\" : r[field])\r\n                            .split(\/[,;\u00b7\\n]+\/)\r\n                            .map(s => s.trim())\r\n                            .filter(Boolean)\r\n                            .forEach(item => {\r\n                                const key = normalizeLookupValue(item);\r\n                                if (!seen.has(key)) {\r\n                                    seen.add(key);\r\n                                    out.push(item);\r\n                                }\r\n                            });\r\n                    });\r\n                    return out.join(separator);\r\n                };\r\n\r\n                let totalRequested = 0;\r\n                let totalObtained = 0;\r\n                let anyFinancingFiled = false;\r\n                const breakdown = [];\r\n                sorted.forEach(r => {\r\n                    const split = getRequestedAndObtainedForRow(r);\r\n                    \/\/ Les totaux de l'asso ne comptent que l'ann\u00e9e de r\u00e9f\u00e9rence ;\r\n                    \/\/ les autres ann\u00e9es restent dans le breakdown (affichage).\r\n                    if (isCountedYear(r.year)) {\r\n                        totalRequested += split.requested;\r\n                        totalObtained += split.obtained;\r\n                    }\r\n                    const ff = normalizeLookupValue(r.financingFiled);\r\n                    if (ff && (ff === \"oui\" || ff.startsWith(\"oui\"))) anyFinancingFiled = true;\r\n                    \/\/ Ventilation: chaque ligne source garde son ann\u00e9e et son montant pour\r\n                    \/\/ que les totaux puissent reprendre tout l'historique de l'association.\r\n                    if (split.requested > 0 || split.obtained > 0) {\r\n                        breakdown.push({\r\n                            domain: r.domain || base.domain || \"\",\r\n                            item: r.item || base.item || \"\",\r\n                            sub: r.sub || base.sub || \"\",\r\n                            requested: split.requested,\r\n                            obtained: split.obtained,\r\n                            year: r.year == null ? null : r.year\r\n                        });\r\n                    }\r\n                });\r\n\r\n                merged.push({\r\n                    ...base,\r\n                    id: base.id || `merged-${merged.length + 1}`,\r\n                    name: pickFirst(\"name\") || base.name,\r\n                    contactName: pickFirst(\"contactName\"),\r\n                    email: pickFirst(\"email\"),\r\n                    phone: pickFirst(\"phone\"),\r\n                    website: pickFirst(\"website\"),\r\n                    video: pickFirst(\"video\"),\r\n                    projectLinks: unionList(\"projectLinks\", \"\\n\"),\r\n                    zone: pickFirst(\"zone\") || base.zone,\r\n                    metropoleContact: pickFirst(\"metropoleContact\"),\r\n                    publicTarget: pickFirst(\"publicTarget\"),\r\n                    logo: pickFirst(\"logo\"),\r\n                    desc: pickLongest(\"desc\") || base.desc,\r\n                    financingType: unionList(\"financingType\", \" \u00b7 \"),\r\n                    partnerCountry: unionList(\"partnerCountry\", \", \"),\r\n                    financingFiled: anyFinancingFiled ? \"Oui\" : (pickFirst(\"financingFiled\") || \"\"),\r\n                    amountRequested: totalRequested > 0 ? String(totalRequested) : \"\",\r\n                    amountObtained: totalObtained > 0 ? String(totalObtained) : \"\",\r\n                    amount: \"\",\r\n                    breakdown,\r\n                    domain: base.domain,\r\n                    item: base.item,\r\n                    sub: base.sub,\r\n                    rawDomain: base.rawDomain,\r\n                    rawItem: base.rawItem,\r\n                    rawSub: base.rawSub,\r\n                    year: base.year\r\n                });\r\n            });\r\n\r\n            return merged;\r\n        }\r\n\r\n        let lastAssociationImportSignature = \"\";\r\n        function loadAssociationsFromCsv() {\r\n            const sourceUrl = configuredAssociationsCsvUrl();\r\n            if (!sourceUrl) return Promise.resolve();\r\n            if (sourceUrl.includes(\"docs.google.com\/forms\/\")) {\r\n                console.warn(\"Collez une URL CSV de Google Sheet dans associationsCsvUrl, pas l'URL du Google Form.\");\r\n                return Promise.resolve();\r\n            }\r\n            return loadAssociationRows(sourceUrl)\r\n                .then(rows => {\r\n                    const rawImported = rows\r\n                        .map(normalizeImportedAssociation)\r\n                        .filter(asso => asso.name && (asso.domain || asso.item || asso.sub));\r\n                    if (!rawImported.length) {\r\n                        console.warn(\"Aucune ligne Google Sheet valide. Colonnes attendues au minimum: nom + domaine\/thematique\/sous-thematique.\");\r\n                        restoreLocalAssociationsFallback();\r\n                        return;\r\n                    }\r\n                    const calculationRows = buildFundingCalculationRows(rawImported);\r\n                    const imported = mergeAssociationDuplicatesByName(rawImported);\r\n                    const signature = JSON.stringify(imported.map(asso => ({\r\n                        id: asso.id,\r\n                        name: asso.name,\r\n                        zone: asso.zone,\r\n                        domain: asso.domain,\r\n                        item: asso.item,\r\n                        sub: asso.sub,\r\n                        desc: asso.desc,\r\n                        email: asso.email,\r\n                        website: asso.website,\r\n                        video: asso.video,\r\n                        phone: asso.phone,\r\n                        contactName: asso.contactName,\r\n                        logo: asso.logo,\r\n                        publicTarget: asso.publicTarget,\r\n                        projectLinks: asso.projectLinks,\r\n                        financingFiled: asso.financingFiled,\r\n                        financingType: asso.financingType,\r\n                        partnerCountry: asso.partnerCountry,\r\n                        amount: asso.amount,\r\n                        amountRequested: asso.amountRequested,\r\n                        amountObtained: asso.amountObtained,\r\n                        metropoleContact: asso.metropoleContact,\r\n                        rawDomain: asso.rawDomain,\r\n                        rawItem: asso.rawItem,\r\n                        rawSub: asso.rawSub,\r\n                        year: asso.year,\r\n                        breakdown: asso.breakdown\r\n                    })).concat(calculationRows.map(row => ({\r\n                        calc: true,\r\n                        name: row.name,\r\n                        zone: row.zone,\r\n                        year: row.year,\r\n                        domain: row.domain,\r\n                        item: row.item,\r\n                        sub: row.sub,\r\n                        requested: row.requested,\r\n                        obtained: row.obtained\r\n                    }))));\r\n                    if (signature === lastAssociationImportSignature) return;\r\n                    lastAssociationImportSignature = signature;\r\n                    setAssociationSource(imported, calculationRows);\r\n                })\r\n                .catch(error => {\r\n                    console.error(\"Impossible de charger les associations depuis la Google Sheet.\", error);\r\n                    restoreLocalAssociationsFallback();\r\n                });\r\n        }\r\n\r\n        if (zoneSelect) {\r\n            zoneSelect.addEventListener(\"change\", event => {\r\n                activeZone = event.target.value;\r\n                renderZoneSelect();\r\n                applyZoneFilter();\r\n            });\r\n        }\r\n        renderZoneSelect();\r\n\r\n        const fundingFilters = document.getElementById('funding-filters');\r\n        fundingFilters.querySelector('.filter-label').textContent = 'Choisir un type';\r\n\r\n        function setFundingFiltersVisible(show) {\r\n            if (show) {\r\n                fundingFilters.removeAttribute('hidden');\r\n                fundingFilters.classList.add('entering');\r\n                requestAnimationFrame(() => fundingFilters.classList.remove('entering'));\r\n            } else {\r\n                fundingFilters.setAttribute('hidden', '');\r\n            }\r\n        }\r\n\r\n        function resetFundingFilters() {\r\n            Object.keys(activeFundingCategories).forEach(k => { activeFundingCategories[k] = false; });\r\n            fundingFilters.querySelectorAll('.filter-btn').forEach(btn => {\r\n                btn.classList.remove('active');\r\n                btn.setAttribute('aria-pressed', 'false');\r\n            });\r\n        }\r\n        resetFundingFilters();\r\n\r\n        fundingToggle.addEventListener('click', event => {\r\n            const button = event.target.closest('.toggle-btn');\r\n            if (!button) return;\r\n            fundingMode = button.dataset.mode;\r\n            fundingToggle.querySelectorAll('.toggle-btn').forEach(btn => {\r\n                const isActive = btn === button;\r\n                btn.classList.toggle('active', isActive);\r\n                btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');\r\n            });\r\n            if (fundingMode === \"category\") {\r\n                setFundingFiltersVisible(true);\r\n            } else {\r\n                setFundingFiltersVisible(false);\r\n                resetFundingFilters();\r\n            }\r\n            \/\/ Synchronise la l\u00e9gende AVANT le reflow pour que la largeur du\r\n            \/\/ chart soit d\u00e9j\u00e0 \u00e0 jour quand la roue se recalcule.\r\n            renderFundingLegend();\r\n            redrawForCurrentSize();\r\n        });\r\n\r\n        \/\/ Interrupteur \"Associations\" : la roue est vide d'assos par d\u00e9faut,\r\n        \/\/ ce switch on\/off r\u00e9v\u00e8le \/ masque les bulles de comptage.\r\n        const assosToggle = document.getElementById('assos-toggle');\r\n        assosToggle.addEventListener('click', () => {\r\n            showAssociations = !showAssociations;\r\n            assosToggle.classList.toggle('active', showAssociations);\r\n            assosToggle.setAttribute('aria-checked', showAssociations ? 'true' : 'false');\r\n            refreshBubbleVisibility();\r\n        });\r\n\r\n\r\n        fundingFilters.addEventListener('click', event => {\r\n            const btn = event.target.closest('.filter-btn');\r\n            if (!btn) return;\r\n            const group = btn.dataset.group;\r\n            if (!(group in activeFundingCategories)) return;\r\n            activeFundingCategories[group] = !activeFundingCategories[group];\r\n            btn.classList.toggle('active', activeFundingCategories[group]);\r\n            btn.setAttribute('aria-pressed', activeFundingCategories[group] ? 'true' : 'false');\r\n            \/\/ v4 \u2014 On rend la l\u00e9gende AVANT le reflow : si elle appara\u00eet\/dispara\u00eet\r\n            \/\/ la largeur du chart change (flex layout), et la roue doit \u00eatre\r\n            \/\/ recalcul\u00e9e pour s'adapter \u00e0 ce nouveau gabarit.\r\n            const legendVisibilityChanged = renderFundingLegend();\r\n            if (legendVisibilityChanged) {\r\n                redrawForCurrentSize();\r\n            } else {\r\n                updateFundingVisibility();\r\n            }\r\n        });\r\n\r\n        let lastSubBubble = null;\r\n\r\n        function openBubbleFromWheel(bubble, opts) {\r\n            if (!bubble || !bubble.count) return;\r\n            const list = (bubble.assos || []).filter(Boolean);\r\n            if (list.length === 1) {\r\n                openSidebar(list[0], bubble, opts);\r\n                return;\r\n            }\r\n            openSubListSidebar(bubble, opts);\r\n        }\r\n\r\n        function openCategorySidebar(node, opts) {\r\n            if (!node || !node.data) return;\r\n            selectedAsso = null;\r\n            lastSubBubble = null;\r\n            const isItem = node.depth === 2;\r\n            const isDomain = node.depth === 1;\r\n            if (!isItem && !isDomain) return;\r\n            pushSidebarState({ kind: 'category', node }, opts);\r\n            \/\/ Synchronise la roue : zoom sur le domaine\/cat\u00e9gorie cliqu\u00e9\u00b7e\r\n            if (typeof clickedArc === 'function') clickedArc(null, node);\r\n            \/\/ M\u00e9morise pour le re-prompt \u00e0 la fermeture\r\n            const domainNodeForCtx = isItem ? node.parent : node;\r\n            lastPromptContext = {\r\n                eyebrow: isItem ? 'Cat\u00e9gorie' : 'Domaine',\r\n                title: node.data.name,\r\n                color: domainNodeForCtx.data.color,\r\n                icon: '\u2197',\r\n                action: () => openCategorySidebar(node)\r\n            };\r\n\r\n            const domainNode = isItem ? node.parent : node;\r\n            const domainName = domainNode.data.name;\r\n            const color = domainNode.data.color || \"#6d4f84\";\r\n            const title = node.data.name;\r\n            const kindLabel = isItem ? \"Cat\u00e9gorie\" : \"Domaine\";\r\n\r\n            \/\/ Bubbles concern\u00e9s par cette cat\u00e9gorie \/ ce domaine\r\n            const bubbles = (typeof dotData !== \"undefined\" ? dotData : []).filter(b => {\r\n                if (isItem) return b.subNode.parent === node;\r\n                return b.subNode.parent.parent === node;\r\n            });\r\n\r\n            const allAssos = bubbles.flatMap(b => b.assos);\r\n            const totalAssos = allAssos.length;\r\n\r\n            \/\/ Totaux selon la classification d'origine de chaque ligne du Sheet.\r\n            \/\/ Une association multi-domaines ne d\u00e9place donc pas ses anciens montants.\r\n            const matchFn = buildContextMatchForCategoryNode(node, isItem);\r\n            const totals = computeFundingTotalsForContext(activeFundingCalculationRows(), matchFn);\r\n            const totalHTML = renderFundingTotalsHTML(totals, Math.max(totalAssos, totals.requestedCount, totals.obtainedCount));\r\n\r\n            \/\/ Cards de navigation : sous-cat\u00e9gories (si item) ou cat\u00e9gories (si domaine)\r\n            let navItems = [];\r\n            if (isItem) {\r\n                navItems = bubbles\r\n                    .filter(b => b.count > 0)\r\n                    .sort((a, b) => (a.subName || \"\").localeCompare(b.subName || \"\", \"fr\"))\r\n                    .map(b => ({ kind: \"sub\", bubble: b, label: b.subName, count: b.count }));\r\n            } else {\r\n                \/\/ Domaine : regroupe par item\r\n                const itemMap = new Map();\r\n                bubbles.forEach(b => {\r\n                    const key = b.itemName;\r\n                    if (!itemMap.has(key)) itemMap.set(key, { itemNode: b.subNode.parent, label: key, count: 0, assos: [] });\r\n                    const entry = itemMap.get(key);\r\n                    entry.count += b.count;\r\n                    entry.assos = entry.assos.concat(b.assos);\r\n                });\r\n                navItems = Array.from(itemMap.values())\r\n                    .filter(e => e.count > 0)\r\n                    .sort((a, b) => a.label.localeCompare(b.label, \"fr\"))\r\n                    .map(e => ({ kind: \"item\", node: e.itemNode, label: e.label, count: e.count }));\r\n            }\r\n\r\n            const cards = navItems.map((item, i) => `\r\n                <button type=\"button\" class=\"bubble-asso-card\" data-nav-index=\"${i}\">\r\n                    <span class=\"bubble-asso-name\">${escapeHTML(item.label)}<\/span>\r\n                    <span style=\"margin-left:auto; color:#6d7580; font-weight:700; font-size:0.78rem;\">${item.count}<\/span>\r\n                <\/button>\r\n            `).join(\"\");\r\n\r\n            setSidebarContent(`\r\n                <div class=\"tag-domain\" style=\"background-color: ${escapeHTML(color)}\">${escapeHTML(domainName)}<\/div>\r\n                <h2 class=\"asso-title\">${escapeHTML(title)}<\/h2>\r\n                <div class=\"asso-theme\">\r\n                    <span>${escapeHTML(kindLabel)}<\/span>\r\n                    <span style=\"color: #cbd5e1\">\u2022<\/span>\r\n                    <span>${totalAssos} association${totalAssos > 1 ? \"s\" : \"\"}${activeZone !== \"Toutes\" ? \" \u00b7 \" + escapeHTML(activeZone) : \"\"}<\/span>\r\n                <\/div>\r\n                ${totalHTML}\r\n                <div class=\"bubble-asso-list\">${cards}<\/div>\r\n            `);\r\n            sidebarContent.querySelectorAll(\".bubble-asso-card\").forEach(card => {\r\n                card.addEventListener(\"click\", () => {\r\n                    const idx = Number(card.dataset.navIndex);\r\n                    const target = navItems[idx];\r\n                    if (!target) return;\r\n                    if (target.kind === \"sub\") {\r\n                        openBubbleFromWheel(target.bubble);\r\n                    } else if (target.kind === \"item\") {\r\n                        openCategorySidebar(target.node);\r\n                    }\r\n                });\r\n            });\r\n            setSidebarOpen(true);\r\n        }\r\n\r\n        function openSubListSidebar(bubble, opts) {\r\n            selectedAsso = null;\r\n            lastSubBubble = bubble;\r\n            pushSidebarState({ kind: 'sublist', bubble }, opts);\r\n            \/\/ Synchronise la roue : zoom sur la cat\u00e9gorie parente (depth 2)\r\n            if (typeof clickedArc === 'function' && bubble.subNode && bubble.subNode.parent) {\r\n                clickedArc(null, bubble.subNode.parent);\r\n            }\r\n            lastPromptContext = {\r\n                eyebrow: 'Sous-cat\u00e9gorie',\r\n                title: bubble.subName,\r\n                color: bubble.color,\r\n                icon: String(bubble.count || ''),\r\n                action: () => openSubListSidebar(bubble)\r\n            };\r\n            const { subName, itemName, domainName, color, assos, count } = bubble;\r\n            const list = assos.slice().sort((a, b) => (a.name || \"\").localeCompare(b.name || \"\", \"fr\"));\r\n            const cards = list.map((a, i) => `\r\n                <button type=\"button\" class=\"bubble-asso-card\" data-asso-index=\"${i}\">\r\n                    <span class=\"bubble-asso-name\">${escapeHTML(a.name)}<\/span>\r\n                <\/button>\r\n            `).join(\"\");\r\n\r\n            \/\/ Totaux selon la classification d'origine de chaque ligne du Sheet.\r\n            const matchFn = buildContextMatchForBubble(bubble);\r\n            const totals = computeFundingTotalsForContext(activeFundingCalculationRows(), matchFn);\r\n            const totalHTML = renderFundingTotalsHTML(totals, Math.max(count, totals.requestedCount, totals.obtainedCount));\r\n\r\n            setSidebarContent(`\r\n                <div class=\"tag-domain\" style=\"background-color: ${escapeHTML(color)}\">${escapeHTML(domainName)}<\/div>\r\n                <h2 class=\"asso-title\">${escapeHTML(subName)}<\/h2>\r\n                <div class=\"asso-theme\">\r\n                    <span>${escapeHTML(itemName)}<\/span>\r\n                    <span style=\"color: #cbd5e1\">\u2022<\/span>\r\n                    <span>${count} association${count > 1 ? \"s\" : \"\"}${activeZone !== \"Toutes\" ? \" \u00b7 \" + escapeHTML(activeZone) : \"\"}<\/span>\r\n                <\/div>\r\n                ${totalHTML}\r\n                <div class=\"bubble-asso-list\">${cards}<\/div>\r\n            `);\r\n            sidebarContent.querySelectorAll(\".bubble-asso-card\").forEach(card => {\r\n                card.addEventListener(\"click\", () => {\r\n                    const idx = Number(card.dataset.assoIndex);\r\n                    const target = list[idx];\r\n                    if (target) openSidebar(target, bubble);\r\n                });\r\n            });\r\n            setSidebarOpen(true);\r\n        }\r\n\r\n        function openSidebar(asso, originBubble, opts) {\r\n            selectedAsso = asso;\r\n            if (originBubble) lastSubBubble = originBubble;\r\n            pushSidebarState({ kind: 'asso', asso, originBubble: originBubble || lastSubBubble }, opts);\r\n            const targetWheelNode = originBubble && originBubble.subNode ? originBubble.subNode.parent : null;\r\n            if (\r\n                !(opts && opts.fromHistory) &&\r\n                targetWheelNode &&\r\n                selectedNode !== targetWheelNode\r\n            ) {\r\n                clickedArc(null, targetWheelNode);\r\n            }\r\n            lastPromptContext = {\r\n                eyebrow: 'Association',\r\n                title: asso.name,\r\n                color: asso.color || 'var(--accent-color)',\r\n                icon: '\ud83d\udc41',\r\n                action: () => openSidebar(asso, originBubble || lastSubBubble)\r\n            };\r\n            const backHTML = \"\";\r\n            const websiteUrl = safeWebsiteUrl(asso.website);\r\n            const emailHref = safeEmailHref(asso.email);\r\n            const phoneHref = safePhoneHref(asso.phone);\r\n            const projectLinks = splitLinkValues(asso.projectLinks);\r\n\r\n            const arrowSvg = `<svg class=\"profile-contact-arrow\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"9 18 15 12 9 6\"><\/polyline><\/svg>`;\r\n            const websiteDisplay = String(asso.website || \"\").trim().replace(\/^https?:\\\/\\\/\/i, \"\").replace(\/\\\/$\/, \"\");\r\n            const contactRows = [];\r\n            if (websiteUrl) {\r\n                contactRows.push(`\r\n                    <a href=\"${escapeHTML(websiteUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"profile-contact-row\">\r\n                        <span class=\"profile-contact-icon\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"><\/circle><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"><\/line><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"><\/path><\/svg><\/span>\r\n                        <span class=\"profile-contact-body\">\r\n                            <span class=\"profile-contact-label\">Site internet<\/span>\r\n                            <span class=\"profile-contact-value\">${escapeHTML(websiteDisplay)}<\/span>\r\n                        <\/span>\r\n                        ${arrowSvg}\r\n                    <\/a>\r\n                `);\r\n            } else {\r\n                contactRows.push(`\r\n                    <div class=\"profile-contact-row profile-contact-row--static\">\r\n                        <span class=\"profile-contact-icon\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"><\/circle><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"><\/line><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"><\/path><\/svg><\/span>\r\n                        <span class=\"profile-contact-body\">\r\n                            <span class=\"profile-contact-label\">Site internet<\/span>\r\n                            <span class=\"profile-contact-value\">Non renseign\u00e9<\/span>\r\n                        <\/span>\r\n                    <\/div>\r\n                `);\r\n            }\r\n            if (emailHref) {\r\n                contactRows.push(`\r\n                    <a href=\"${escapeHTML(emailHref)}\" class=\"profile-contact-row\">\r\n                        <span class=\"profile-contact-icon\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"><\/rect><path d=\"m22 7-10 6L2 7\"><\/path><\/svg><\/span>\r\n                        <span class=\"profile-contact-body\">\r\n                            <span class=\"profile-contact-label\">Email<\/span>\r\n                            <span class=\"profile-contact-value\">${escapeHTML(asso.email)}<\/span>\r\n                        <\/span>\r\n                        ${arrowSvg}\r\n                    <\/a>\r\n                `);\r\n            }\r\n            if (phoneHref) {\r\n                contactRows.push(`\r\n                    <a href=\"${escapeHTML(phoneHref)}\" class=\"profile-contact-row\">\r\n                        <span class=\"profile-contact-icon\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.08 4.18 2 2 0 0 1 4.06 2h3a2 2 0 0 1 2 1.72c.12.9.33 1.77.62 2.6a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.48-1.14a2 2 0 0 1 2.11-.45c.83.29 1.7.5 2.6.62A2 2 0 0 1 22 16.92z\"><\/path><\/svg><\/span>\r\n                        <span class=\"profile-contact-body\">\r\n                            <span class=\"profile-contact-label\">T\u00e9l\u00e9phone<\/span>\r\n                            <span class=\"profile-contact-value\">${escapeHTML(asso.phone)}<\/span>\r\n                        <\/span>\r\n                        ${arrowSvg}\r\n                    <\/a>\r\n                `);\r\n            }\r\n            const metropoleContactValue = String(asso.metropoleContact || \"\").trim();\r\n            if (metropoleContactValue) {\r\n                contactRows.push(`\r\n                    <div class=\"profile-contact-row profile-contact-row--static\">\r\n                        <span class=\"profile-contact-icon\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 21h18\"><\/path><path d=\"M5 21V7l7-4 7 4v14\"><\/path><path d=\"M9 9h1\"><\/path><path d=\"M9 13h1\"><\/path><path d=\"M9 17h1\"><\/path><path d=\"M14 9h1\"><\/path><path d=\"M14 13h1\"><\/path><path d=\"M14 17h1\"><\/path><\/svg><\/span>\r\n                        <span class=\"profile-contact-body\">\r\n                            <span class=\"profile-contact-label\">Interlocuteur M\u00e9tropole<\/span>\r\n                            <span class=\"profile-contact-value\">${escapeHTML(metropoleContactValue)}<\/span>\r\n                        <\/span>\r\n                    <\/div>\r\n                `);\r\n            }\r\n            const ctaHTML = contactRows.length\r\n                ? `<div class=\"profile-contact-list\">${contactRows.join(\"\")}<\/div>`\r\n                : `<p class=\"profile-contact-meta\">Aucun contact direct renseign\u00e9. Donn\u00e9es import\u00e9es depuis la feuille de cartographie.<\/p>`;\r\n\r\n            const pillItems = [];\r\n            if (asso.publicTarget) {\r\n                pillItems.push(`\r\n                    <div class=\"profile-pill\">\r\n                        <span class=\"profile-pill-label\">Public cible<\/span>\r\n                        <span class=\"profile-pill-value\">${escapeHTML(asso.publicTarget)}<\/span>\r\n                    <\/div>\r\n                `);\r\n            }\r\n            if (asso.financingType) {\r\n                pillItems.push(`\r\n                    <div class=\"profile-pill\">\r\n                        <span class=\"profile-pill-label\">Type de financement<\/span>\r\n                        <span class=\"profile-pill-value\">${escapeHTML(asso.financingType)}<\/span>\r\n                    <\/div>\r\n                `);\r\n            }\r\n            if (asso.partnerCountry) {\r\n                pillItems.push(`\r\n                    <div class=\"profile-pill\">\r\n                        <span class=\"profile-pill-label\">Pays partenaire<\/span>\r\n                        <span class=\"profile-pill-value\">${escapeHTML(asso.partnerCountry)}<\/span>\r\n                    <\/div>\r\n                `);\r\n            }\r\n            const pillsHTML = pillItems.length ? `<div class=\"profile-pills\">${pillItems.join(\"\")}<\/div>` : \"\";\r\n\r\n            const obtainedAmount = String(asso.amountObtained || asso.amount || \"\").trim();\r\n            const requestedAmount = String(asso.amountRequested || \"\").trim();\r\n            const fmtEur = n => new Intl.NumberFormat(\"fr-FR\").format(Math.round(Number(n) || 0)) + \" \u20ac\";\r\n            const pfLine = (label, n) => `<span class=\"pf-line\"><span class=\"pf-k\">${label}<\/span><span class=\"pf-v\">${fmtEur(n)}<\/span><\/span>`;\r\n\r\n            \/\/ Carte \u00ab D\u00e9p\u00f4t de projet europ\u00e9en \u00bb.\r\n            const depotVal = formatFinancingFiled(asso.financingFiled);\r\n            const depotHTML = String(depotVal || \"\").trim()\r\n                ? `<div class=\"profile-funding-row\">\r\n                        <span class=\"profile-funding-row-label\">D\u00e9p\u00f4t de projet europ\u00e9en<\/span>\r\n                        <span class=\"profile-funding-row-value\">${escapeHTML(depotVal)}<\/span>\r\n                    <\/div>`\r\n                : \"\";\r\n\r\n            \/\/ D\u00e9tail PAR ANN\u00c9E : une carte par ann\u00e9e, sollicit\u00e9 et obtenu sur\r\n            \/\/ deux lignes align\u00e9es (libell\u00e9 \u00e0 gauche, montant \u00e0 droite). Pas de\r\n            \/\/ total : il ferait doublon avec la somme des ann\u00e9es.\r\n            const byYear = {};\r\n            (Array.isArray(asso.breakdown) ? asso.breakdown : []).forEach(b => {\r\n                if (b.year != null) {\r\n                    const y = byYear[b.year] || (byYear[b.year] = { req: 0, obt: 0 });\r\n                    y.req += Number(b.requested || 0);\r\n                    y.obt += Number(b.obtained || 0);\r\n                }\r\n            });\r\n            const yearKeys = Object.keys(byYear).map(Number).sort((a, b) => b - a);\r\n            const yearsHTML = yearKeys.map(y => {\r\n                const v = byYear[y];\r\n                \/\/ Pour l'ann\u00e9e de r\u00e9f\u00e9rence (2026), on affiche TOUJOURS sollicit\u00e9\r\n                \/\/ ET obtenu, m\u00eame \u00e0 0 \u20ac (\u00ab pas encore obtenu \u00bb).\r\n                const isRef = (y === fundingReferenceYear);\r\n                let lines = \"\";\r\n                if (v.req > 0 || isRef) lines += pfLine(\"Sollicit\u00e9\", v.req);\r\n                if (v.obt > 0 || isRef) lines += pfLine(\"Obtenu\", v.obt);\r\n                if (!lines) return \"\";\r\n                return `<div class=\"profile-funding-row\">\r\n                            <span class=\"profile-funding-row-label\">Financements ${y}<\/span>\r\n                            <div class=\"pf-amounts\">${lines}<\/div>\r\n                        <\/div>`;\r\n            }).join(\"\");\r\n\r\n            \/\/ Secours : aucune ann\u00e9e renseign\u00e9e mais des montants globaux existent.\r\n            let fallbackHTML = \"\";\r\n            if (!yearKeys.length) {\r\n                let fl = \"\";\r\n                if (requestedAmount) fl += pfLine(\"Sollicit\u00e9\", parseAmountToNumber(requestedAmount));\r\n                if (obtainedAmount) fl += pfLine(\"Obtenu\", parseAmountToNumber(obtainedAmount));\r\n                if (fl) fallbackHTML = `<div class=\"profile-funding-row\">\r\n                            <span class=\"profile-funding-row-label\">Financements europ\u00e9ens<\/span>\r\n                            <div class=\"pf-amounts\">${fl}<\/div>\r\n                        <\/div>`;\r\n            }\r\n\r\n            const fundingInner = depotHTML + yearsHTML + fallbackHTML;\r\n            const fundingSummaryHTML = fundingInner\r\n                ? `<div class=\"profile-funding-summary\">${fundingInner}<\/div>`\r\n                : \"\";\r\n\r\n            const fundingSectionHTML = fundingSummaryHTML\r\n                ? `\r\n                    <section class=\"profile-section\">\r\n                        <div class=\"profile-section-eyebrow\">Financements europ\u00e9ens<\/div>\r\n                        ${fundingSummaryHTML}\r\n                    <\/section>\r\n                `\r\n                : \"\";\r\n            const projectLinksHTML = projectLinks.length\r\n                ? `\r\n                    <section class=\"profile-section\">\r\n                        <div class=\"profile-section-eyebrow\">Fiches Erasmus+<\/div>\r\n                        <div class=\"profile-project-links\">\r\n                            ${projectLinks.map((url, index) => `\r\n                                <a href=\"${escapeHTML(url)}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"profile-erasmus-link\">Fiche Erasmus+${projectLinks.length > 1 ? \" \" + (index + 1) : \"\"} \u2197<\/a>\r\n                            `).join(\"\")}\r\n                        <\/div>\r\n                    <\/section>\r\n                `\r\n                : \"\";\r\n\r\n            \/\/ Vid\u00e9o de pr\u00e9sentation : miniature cliquable (le lecteur ne se charge\r\n            \/\/ qu'au clic). Priorit\u00e9 au lien du Sheet, sinon \u00e0 la liste locale.\r\n            const video = parseYouTubeVideo(asso.video) || parseYouTubeVideo(knownAssociationVideo(asso.name));\r\n            const videoSectionHTML = video\r\n                ? `\r\n                    <section class=\"profile-section\">\r\n                        <div class=\"profile-section-eyebrow\">Vid\u00e9o de pr\u00e9sentation<\/div>\r\n                        <button type=\"button\" class=\"profile-video\" data-video-id=\"${escapeHTML(video.id)}\" data-video-start=\"${video.start}\" data-video-watch=\"${escapeHTML(video.watchUrl)}\" aria-label=\"Lire la vid\u00e9o de pr\u00e9sentation de ${escapeHTML(asso.name)}\"${video.isShort ? ' style=\"--profile-video-ratio: 160%; --profile-video-max: 260px;\"' : \"\"}>\r\n                            <img decoding=\"async\" class=\"profile-video-thumb\" src=\"${escapeHTML(video.thumbUrl)}\" alt=\"Miniature de la vid\u00e9o de pr\u00e9sentation de ${escapeHTML(asso.name)}\" loading=\"lazy\">\r\n                            <span class=\"profile-video-veil\"><\/span>\r\n                            <span class=\"profile-video-play\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M8 5.5v13l11-6.5-11-6.5z\"><\/path><\/svg><\/span>\r\n                            <span class=\"profile-video-caption\">Lire la vid\u00e9o<\/span>\r\n                        <\/button>\r\n                        <a href=\"${escapeHTML(video.watchUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"profile-video-link\">Voir sur YouTube \u2197<\/a>\r\n                    <\/section>\r\n                `\r\n                : \"\";\r\n\r\n            const heroMetaParts = [];\r\n            const cleanZone = String(asso.zone || \"\").trim();\r\n            if (cleanZone && cleanZone !== \"Sans zone\") {\r\n                heroMetaParts.push(`\r\n                    <span class=\"profile-hero-zone\">\r\n                        <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"><\/path><circle cx=\"12\" cy=\"10\" r=\"3\"><\/circle><\/svg>\r\n                        ${escapeHTML(cleanZone)}\r\n                    <\/span>\r\n                `);\r\n            }\r\n            if (asso.item) heroMetaParts.push(`<span>${escapeHTML(asso.item)}<\/span>`);\r\n            if (asso.sub && canonicalLookupValue(asso.sub) !== canonicalLookupValue(asso.item)) {\r\n                heroMetaParts.push(`<span>${escapeHTML(asso.sub)}<\/span>`);\r\n            }\r\n            const heroMetaHTML = heroMetaParts.length\r\n                ? `<div class=\"profile-hero-meta\">${heroMetaParts.join('<span class=\"profile-hero-sep\">\u00b7<\/span>')}<\/div>`\r\n                : \"\";\r\n\r\n            setSidebarContent(`\r\n                <div class=\"profile-card\" style=\"--profile-color: ${escapeHTML(asso.color)}; --profile-color-soft: ${escapeHTML(asso.color)}14; --profile-color-mid: ${escapeHTML(asso.color)}38;\">\r\n                    <div class=\"profile-hero\">\r\n                        ${backHTML}\r\n                        <div class=\"profile-hero-domain\">${escapeHTML(asso.domain)}<\/div>\r\n                        <h2 class=\"profile-hero-title\">${escapeHTML(asso.name)}<\/h2>\r\n                        ${heroMetaHTML}\r\n                    <\/div>\r\n                    ${pillsHTML}\r\n                    <section class=\"profile-section\">\r\n                        <div class=\"profile-section-eyebrow\">Projet<\/div>\r\n                        <p class=\"profile-description\">${escapeHTML(asso.desc)}<\/p>\r\n                    <\/section>\r\n                    ${videoSectionHTML}\r\n                    ${fundingSectionHTML}\r\n                    ${projectLinksHTML}\r\n                    <section class=\"profile-section\">\r\n                        <div class=\"profile-section-eyebrow\">Contact<\/div>\r\n                        ${ctaHTML}\r\n                    <\/section>\r\n                <\/div>\r\n            `);\r\n            const backBtn = document.getElementById('bubble-back-btn');\r\n            if (backBtn) backBtn.addEventListener('click', () => {\r\n                if (lastSubBubble) openSubListSidebar(lastSubBubble);\r\n            });\r\n            bindProfileVideo();\r\n            setSidebarOpen(true);\r\n        }\r\n\r\n        function closeSidebarFromButton(event) {\r\n            if (event) {\r\n                event.preventDefault();\r\n                event.stopPropagation();\r\n            }\r\n            setSidebarOpen(false);\r\n            selectedAsso = null;\r\n            lastSubBubble = null;\r\n            sidebarStack = [];\r\n            updateSidebarBackButton();\r\n            hideDetailPrompt(true);\r\n        }\r\n\r\n        closeBtn.addEventListener('click', closeSidebarFromButton);\r\n        closeBtn.addEventListener('pointerup', closeSidebarFromButton);\r\n\r\n        loadAssociationsFromCsv();\r\n        if (configuredAssociationsCsvUrl()) {\r\n            setInterval(loadAssociationsFromCsv, dataRefreshMs);\r\n        }\r\n\r\n        let resizeRAF = null;\r\n        window.addEventListener('resize', () => {\r\n            if (resizeRAF) cancelAnimationFrame(resizeRAF);\r\n            resizeRAF = requestAnimationFrame(() => {\r\n                redrawForCurrentSize(); \r\n                resizeRAF = null;\r\n            });\r\n        });\r\n    \r\n    }\r\n})();\r\n<\/script>","protected":false},"excerpt":{"rendered":"","protected":false},"author":64,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_et_pb_use_builder":"off","_et_pb_old_content":"","_et_gb_content_width":"","footnotes":""},"class_list":["post-23813672","page","type-page","status-publish","hentry"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.3 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>cartographie-des-projets-et-financements - Social Hackers Lab<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/socialhackerslab.com\/en\/cartographie-des-projets-et-financements\/\" \/>\n<meta property=\"og:locale\" content=\"en_GB\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"cartographie-des-projets-et-financements - Social Hackers Lab\" \/>\n<meta property=\"og:url\" content=\"https:\/\/socialhackerslab.com\/en\/cartographie-des-projets-et-financements\/\" \/>\n<meta property=\"og:site_name\" content=\"Social Hackers Lab\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/profile.php?id=61561806211570\" \/>\n<meta property=\"article:modified_time\" content=\"2026-06-04T15:14:48+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/socialhackerslab.com\/wp-content\/uploads\/2024\/10\/Social-Hacker-Lab-1.png\" \/>\n\t<meta property=\"og:image:width\" content=\"250\" \/>\n\t<meta property=\"og:image:height\" content=\"140\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Estimated reading time\" \/>\n\t<meta name=\"twitter:data1\" content=\"1 minute\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/cartographie-des-projets-et-financements\\\/\",\"url\":\"https:\\\/\\\/socialhackerslab.com\\\/cartographie-des-projets-et-financements\\\/\",\"name\":\"cartographie-des-projets-et-financements - Social Hackers Lab\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/#website\"},\"datePublished\":\"2026-05-20T17:24:49+00:00\",\"dateModified\":\"2026-06-04T15:14:48+00:00\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/cartographie-des-projets-et-financements\\\/#breadcrumb\"},\"inLanguage\":\"en-GB\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/socialhackerslab.com\\\/cartographie-des-projets-et-financements\\\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/cartographie-des-projets-et-financements\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Accueil\",\"item\":\"https:\\\/\\\/socialhackerslab.com\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"cartographie-des-projets-et-financements\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/#website\",\"url\":\"https:\\\/\\\/socialhackerslab.com\\\/\",\"name\":\"SocialHackersLab\",\"description\":\"Hacking Social pour une soci\u00e9t\u00e9 \u00e9galitaire\",\"publisher\":{\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/socialhackerslab.com\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-GB\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/#organization\",\"name\":\"Social Hackers Lab\",\"url\":\"https:\\\/\\\/socialhackerslab.com\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-GB\",\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/socialhackerslab.com\\\/wp-content\\\/uploads\\\/2024\\\/10\\\/BW-SHL.png\",\"contentUrl\":\"https:\\\/\\\/socialhackerslab.com\\\/wp-content\\\/uploads\\\/2024\\\/10\\\/BW-SHL.png\",\"width\":3431,\"height\":3431,\"caption\":\"Social Hackers Lab\"},\"image\":{\"@id\":\"https:\\\/\\\/socialhackerslab.com\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/profile.php?id=61561806211570\",\"https:\\\/\\\/www.linkedin.com\\\/company\\\/socialhackerslab\\\/\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"cartographie-des-projets-et-financements - Social Hackers Lab","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/socialhackerslab.com\/en\/cartographie-des-projets-et-financements\/","og_locale":"en_GB","og_type":"article","og_title":"cartographie-des-projets-et-financements - Social Hackers Lab","og_url":"https:\/\/socialhackerslab.com\/en\/cartographie-des-projets-et-financements\/","og_site_name":"Social Hackers Lab","article_publisher":"https:\/\/www.facebook.com\/profile.php?id=61561806211570","article_modified_time":"2026-06-04T15:14:48+00:00","og_image":[{"width":250,"height":140,"url":"https:\/\/socialhackerslab.com\/wp-content\/uploads\/2024\/10\/Social-Hacker-Lab-1.png","type":"image\/png"}],"twitter_card":"summary_large_image","twitter_misc":{"Estimated reading time":"1 minute"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/socialhackerslab.com\/cartographie-des-projets-et-financements\/","url":"https:\/\/socialhackerslab.com\/cartographie-des-projets-et-financements\/","name":"cartographie-des-projets-et-financements - Social Hackers Lab","isPartOf":{"@id":"https:\/\/socialhackerslab.com\/#website"},"datePublished":"2026-05-20T17:24:49+00:00","dateModified":"2026-06-04T15:14:48+00:00","breadcrumb":{"@id":"https:\/\/socialhackerslab.com\/cartographie-des-projets-et-financements\/#breadcrumb"},"inLanguage":"en-GB","potentialAction":[{"@type":"ReadAction","target":["https:\/\/socialhackerslab.com\/cartographie-des-projets-et-financements\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/socialhackerslab.com\/cartographie-des-projets-et-financements\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Accueil","item":"https:\/\/socialhackerslab.com\/"},{"@type":"ListItem","position":2,"name":"cartographie-des-projets-et-financements"}]},{"@type":"WebSite","@id":"https:\/\/socialhackerslab.com\/#website","url":"https:\/\/socialhackerslab.com\/","name":"SocialHackersLab","description":"Hacking Social pour une soci\u00e9t\u00e9 \u00e9galitaire","publisher":{"@id":"https:\/\/socialhackerslab.com\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/socialhackerslab.com\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-GB"},{"@type":"Organization","@id":"https:\/\/socialhackerslab.com\/#organization","name":"Social Hackers Lab","url":"https:\/\/socialhackerslab.com\/","logo":{"@type":"ImageObject","inLanguage":"en-GB","@id":"https:\/\/socialhackerslab.com\/#\/schema\/logo\/image\/","url":"https:\/\/socialhackerslab.com\/wp-content\/uploads\/2024\/10\/BW-SHL.png","contentUrl":"https:\/\/socialhackerslab.com\/wp-content\/uploads\/2024\/10\/BW-SHL.png","width":3431,"height":3431,"caption":"Social Hackers Lab"},"image":{"@id":"https:\/\/socialhackerslab.com\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/profile.php?id=61561806211570","https:\/\/www.linkedin.com\/company\/socialhackerslab\/"]}]}},"_links":{"self":[{"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/pages\/23813672","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/users\/64"}],"replies":[{"embeddable":true,"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/comments?post=23813672"}],"version-history":[{"count":4,"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/pages\/23813672\/revisions"}],"predecessor-version":[{"id":23813677,"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/pages\/23813672\/revisions\/23813677"}],"wp:attachment":[{"href":"https:\/\/socialhackerslab.com\/en\/wp-json\/wp\/v2\/media?parent=23813672"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}