{"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-05-25T11:05:35","modified_gmt":"2026-05-25T11:05:35","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            flex: 0 0 clamp(200px, 18vw, 270px);\r\n            flex-direction: column;\r\n            align-self: center;\r\n            max-height: calc(100% - 2rem);\r\n            margin: 0 1.1rem 0 0.5rem;\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: rgba(255, 255, 255, 0.92);\r\n            stroke-width: 1.1px;\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            font-weight: 700;\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(24, 38, 52, 0.16);\r\n            stroke-width: 1.35px;\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(15, 28, 40, 0.22);\r\n            stroke-width: 1.05px;\r\n        }\r\n\r\n        #shl-roue-wp text.label.item-label{\r\n            font-weight: 700;\r\n            stroke-width: 1.5px;\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        #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            gap: 0.45rem;\r\n        }\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: 1.4px;\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:#c95c74;\" 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:#3eb5c5;\" 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        \/\/ 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\/1mqcnn5G_8KOjnpHVKbSH_7whB0Au5NUa\/edit?usp=sharing&ouid=109166018559277183226&rtpof=true&sd=true\";\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        const fundingLayers = [\r\n            {\r\n                mode: \"category\",\r\n                group: \"Programmes sectoriels\",\r\n                label: \"ERASMUS +\",\r\n                color: \"#e8a1b1\",\r\n                segments: [[1, 89], [91, 89], [181, 89], [271, 89]],\r\n                labelAngle: -45,\r\n                items: [\"Jeunesse\", \"formation\", \"mobilit\u00e9s europ\u00e9ennes\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Programmes sectoriels\",\r\n                label: \"CERV\",\r\n                color: \"#da7c91\",\r\n                segments: [[1, 89], [271, 89]],\r\n                labelAngle: 12,\r\n                items: [\"Citoyennet\u00e9\", \"droits\", \"valeurs europ\u00e9ennes\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Programmes sectoriels\",\r\n                label: \"Europe Creative\",\r\n                shortLabel: \"Creative\",\r\n                color: \"#c95c74\",\r\n                segments: [[181, 89], [271, 89]],\r\n                labelAngle: -22,\r\n                items: [\"Culture\", \"cr\u00e9ation\", \"coop\u00e9ration artistique\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Coop\u00e9ration territoriale\",\r\n                label: \"URBACT IV \/ CITY-TO-CITY \/ EUI\",\r\n                shortLabel: \"URBACT \/ C2C\",\r\n                color: \"#d4a14c\",\r\n                segments: [[1, 89], [91, 89], [181, 89], [271, 89]],\r\n                labelAngle: 30,\r\n                items: [\"R\u00e9seaux urbains\", \"\u00e9changes entre villes\", \"innovation publique locale\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"FSE +\",\r\n                color: \"#1b8a9e\",\r\n                segments: [[1, 89], [271, 89]],\r\n                labelAngle: -40,\r\n                items: [\"Emploi\", \"formation\", \"inclusion sociale\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"LIFE\",\r\n                color: \"#2da0b2\",\r\n                segments: [[1, 89]],\r\n                labelAngle: 40,\r\n                items: [\"Environnement\", \"climat\", \"transition \u00e9cologique\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"FAMI\",\r\n                color: \"#3eb5c5\",\r\n                segments: [[1, 89], [271, 89]],\r\n                labelAngle: -40,\r\n                items: [\"Asile\", \"migration\", \"int\u00e9gration\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"FEDER \/ FEADER\",\r\n                shortLabel: \"FEDER \/ FEADER\",\r\n                color: \"#54c4d2\",\r\n                segments: [[271, 89]],\r\n                labelAngle: -40,\r\n                items: [\"D\u00e9veloppement r\u00e9gional\", \"ruralit\u00e9\", \"territoires\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"INTERREG\",\r\n                color: \"#70d0dc\",\r\n                segments: [[1, 89], [271, 89]],\r\n                labelAngle: -40,\r\n                items: [\"Coop\u00e9ration transfrontali\u00e8re et territoriale\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"EU4Health\",\r\n                color: \"#7fd6e1\",\r\n                segments: [[1, 89], [91, 89]],\r\n                labelAngle: 40,\r\n                items: [\"Sant\u00e9\", \"pr\u00e9vention\", \"syst\u00e8mes de soin\"]\r\n            },\r\n            {\r\n                mode: \"category\",\r\n                group: \"Fonds structurels\",\r\n                label: \"HORIZON EUROPE\",\r\n                shortLabel: \"HORIZON\",\r\n                color: \"#8edce6\",\r\n                segments: [[1, 89], [91, 89], [181, 89], [271, 89]],\r\n                labelAngle: -40,\r\n                items: [\"Recherche\", \"innovation\", \"coop\u00e9rations europ\u00e9ennes\"]\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        function isCompactLayout(w = width) {\r\n            const coarsePointer = window.matchMedia && window.matchMedia(\"(pointer: coarse)\").matches;\r\n            return w <= 900 || (coarsePointer && w <= 1180);\r\n        }\r\n\r\n        function screenTier(w = width, h = window.innerHeight) {\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        function getChartCenterX(w = width) {\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                \/\/ v4 \u2014 Anneaux fins (~4-7 px \u00d7 ~11 couches) : la roue garde\r\n                \/\/ quasiment toute sa taille, on ne r\u00e9serve qu'un petit liser\u00e9\r\n                \/\/ pour que les rubans ne soient pas coup\u00e9s sur les bords.\r\n                const compact = isCompactLayout(w);\r\n                const ringsReserve = compact ? 30 : 64;\r\n                const fromHeight = visibleHeight \/ 2 - margin - ringsReserve;\r\n                const fromWidth = usableWidth \/ 2 - margin - ringsReserve;\r\n                return Math.max(80, Math.min(fromHeight, fromWidth));\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            const verticalReserve = 22;\r\n            const horizontalReserve = 44;\r\n            const fromHeight = h \/ 2 - margin - verticalReserve;\r\n            const fromWidth = usableWidth \/ 2 - margin - horizontalReserve;\r\n            return Math.max(40, Math.min(fromHeight, fromWidth));\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                const fundingOffset = fundingMode === \"none\" ? 0 : 0;\r\n                return visibleHeight \/ 2 + fundingOffset;\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            const visibleHeight = Math.max(180, height - getCompactSidebarReserve(width, height));\r\n            const outerLimit = Math.min(getUsableChartWidth(width), visibleHeight) \/ 2 - (compact ? 8 : 14);\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            \/\/ v4 \u2014 Anneaux fins : les noms des programmes sont \u00e9crits dans une liste\r\n            \/\/ lat\u00e9rale (#funding-legend), plus \u00e0 l'int\u00e9rieur des arcs. On peut donc\r\n            \/\/ afficher des \u00ab traits \u00bb beaucoup plus minces et lib\u00e9rer du rayon pour\r\n            \/\/ la roue elle-m\u00eame.\r\n            const ringThickness = compact\r\n                ? Math.max(2.5, Math.min(screenTier(width, height) === \"phone\" ? 4 : 5.5, fitThickness))\r\n                : Math.max(4, Math.min(7, 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            const depthMaxScale = isZoomed\r\n                ? (d.depth === 1 ? 1.18 : (d.depth === 2 ? 1.02 : 0.88))\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.74 : 0.66))\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            const usableWidth = d.depth === 1 ? 0.9 : (d.depth === 2 ? 0.78 : 0.82);\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            return computeLabelFontSize(d, isZoomed, box);\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 activeRadius = getActiveRadius(Boolean(selectedNode));\r\n            const sidebarOpen = document.body.classList.contains(\"sidebar-open\");\r\n            const constrained = sidebarOpen && !isCompactLayout(width);\r\n            const fsMultiplier = constrained ? 0.032 : 0.038;\r\n            const fsBaseMax = constrained ? 12 : 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            .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.82 : 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.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.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                    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                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 en 2026\"\r\n                : \"Aucun montant sollicit\u00e9 renseign\u00e9 pour 2026\";\r\n            const obtValue = totals.obtained > 0 ? formatTotalRaised(totals.obtained) : \"\u2014\";\r\n            const obtSub = totals.obtained > 0\r\n                ? \"Montants des financements d\u00e9j\u00e0 valid\u00e9s\"\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 2026<\/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        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        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        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        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                year: parseYearFromValue(readRowValue(row, [\"horodateur\", \"annee\", \"ann\u00e9e\", \"date\", \"exercice\"])),\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                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\"]),\r\n                amount: readRowValue(row, [\"montant leve\", \"montant lev\u00e9\", \"montant leve par l asso\", \"montant lev\u00e9 par l asso\", \"montant\", \"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                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\"]) || readColumnValue(row, 17)\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        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            \r\n            const totalAmount = (explicitReq > 0 || explicitObt > 0) ? (explicitReq + explicitObt) : amt;\r\n            if (totalAmount <= 0) return { requested: 0, obtained: 0 };\r\n            \r\n            const currentYear = getFundingReferenceYear();\r\n            if (asso.year != null) {\r\n                if (asso.year < currentYear) {\r\n                    return { requested: 0, obtained: totalAmount };\r\n                } else {\r\n                    return { requested: totalAmount, obtained: 0 };\r\n                }\r\n            }\r\n            \r\n            if (explicitReq > 0 || explicitObt > 0) {\r\n                return { requested: explicitReq, obtained: explicitObt };\r\n            }\r\n            return { requested: 0, obtained: amt };\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                    totalRequested += split.requested;\r\n                    totalObtained += split.obtained;\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                    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                        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 fundingSummaryRows = [\r\n                [\"D\u00e9p\u00f4t de projet europ\u00e9en\", formatYesNo(asso.financingFiled)],\r\n                [\"Financements sollicit\u00e9s en 2026\", requestedAmount ? formatRaisedAmount(requestedAmount) : \"\"],\r\n                [\"Financements obtenus (d\u00e9j\u00e0 valid\u00e9s)\", obtainedAmount ? formatRaisedAmount(obtainedAmount) : \"\"]\r\n            ].filter(([, value]) => String(value || \"\").trim());\r\n            const fundingSummaryHTML = fundingSummaryRows.length\r\n                ? `\r\n                    <div class=\"profile-funding-summary\">\r\n                        ${fundingSummaryRows.map(([label, value]) => `\r\n                            <div class=\"profile-funding-row\">\r\n                                <span class=\"profile-funding-row-label\">${escapeHTML(label)}<\/span>\r\n                                <span class=\"profile-funding-row-value\">${escapeHTML(value)}<\/span>\r\n                            <\/div>\r\n                        `).join(\"\")}\r\n                    <\/div>\r\n                `\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\">Liens projets europ\u00e9ens<\/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-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=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"><\/path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"><\/path><\/svg><\/span>\r\n                                    <span class=\"profile-contact-body\">\r\n                                        <span class=\"profile-contact-label\">Projet europ\u00e9en ${projectLinks.length > 1 ? index + 1 : \"\"}<\/span>\r\n                                        <span class=\"profile-contact-value\">${escapeHTML(url.replace(\/^https?:\\\/\\\/\/i, \"\"))}<\/span>\r\n                                    <\/span>\r\n                                    ${arrowSvg}\r\n                                <\/a>\r\n                            `).join(\"\")}\r\n                        <\/div>\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                    ${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            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 v27.6 - 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-05-25T11:05:35+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<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-05-25T11:05:35+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-05-25T11:05:35+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","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-05-25T11:05:35+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}]}}