@quatrain/ux-taxonomy 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ThematicTree = void 0;
37
+ const react_1 = __importStar(require("react"));
38
+ const core_1 = require("@mantine/core");
39
+ const icons_react_1 = require("@tabler/icons-react");
40
+ const TreeNodeItem = ({ node, level, controller, selectedIds, searchQuery, onSelect, onAddSubThematic }) => {
41
+ const hasChildren = Boolean(node.children && node.children.length > 0);
42
+ const isExpanded = controller.isExpanded(node.id) || Boolean(searchQuery.trim());
43
+ const isSelected = selectedIds.includes(node.id);
44
+ const matchesSearch = (0, react_1.useMemo)(() => {
45
+ if (!searchQuery.trim())
46
+ return true;
47
+ const q = searchQuery.toLowerCase();
48
+ const matchSelf = node.label.toLowerCase().includes(q) || (node.description && node.description.toLowerCase().includes(q));
49
+ return matchSelf;
50
+ }, [node, searchQuery]);
51
+ if (!matchesSearch && !hasChildren) {
52
+ return null;
53
+ }
54
+ return (react_1.default.createElement(core_1.Box, { style: { marginLeft: level * 14 } },
55
+ react_1.default.createElement(core_1.Group, { justify: "space-between", wrap: "nowrap", p: 6, style: {
56
+ borderRadius: 6,
57
+ backgroundColor: isSelected ? 'var(--mantine-color-blue-light)' : 'transparent',
58
+ cursor: 'pointer',
59
+ transition: 'background-color 150ms ease'
60
+ }, onClick: () => {
61
+ controller.select(node.id);
62
+ if (onSelect)
63
+ onSelect(node);
64
+ } },
65
+ react_1.default.createElement(core_1.Group, { gap: "xs", wrap: "nowrap", style: { flex: 1, minWidth: 0 } },
66
+ hasChildren ? (react_1.default.createElement(core_1.ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (e) => {
67
+ e.stopPropagation();
68
+ controller.toggleExpand(node.id);
69
+ }, "aria-label": isExpanded ? 'Collapse' : 'Expand' }, isExpanded ? react_1.default.createElement(icons_react_1.IconChevronDown, { size: 14 }) : react_1.default.createElement(icons_react_1.IconChevronRight, { size: 14 }))) : (react_1.default.createElement(core_1.Box, { style: { width: 18 } })),
70
+ hasChildren ? (isExpanded ? react_1.default.createElement(icons_react_1.IconFolderOpen, { size: 16, color: "var(--mantine-color-blue-filled)" }) : react_1.default.createElement(icons_react_1.IconFolder, { size: 16, color: "gray" })) : (react_1.default.createElement(icons_react_1.IconFolder, { size: 16, color: "gray" })),
71
+ react_1.default.createElement(core_1.Text, { size: "sm", fw: isSelected ? 600 : 400, truncate: true, style: { flex: 1 } }, node.label)),
72
+ react_1.default.createElement(core_1.Group, { gap: 4, wrap: "nowrap" },
73
+ node.count !== undefined && (react_1.default.createElement(core_1.Badge, { size: "xs", variant: isSelected ? 'filled' : 'light', color: node.color || 'blue' }, node.count)),
74
+ onAddSubThematic && (react_1.default.createElement(core_1.Tooltip, { label: "Ajouter une sous-th\u00E9matique", withArrow: true, position: "right" },
75
+ react_1.default.createElement(core_1.ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (e) => {
76
+ e.stopPropagation();
77
+ onAddSubThematic(node);
78
+ }, "aria-label": "Add sub-thematic" },
79
+ react_1.default.createElement(icons_react_1.IconPlus, { size: 12 })))))),
80
+ hasChildren && (react_1.default.createElement(core_1.Collapse, { in: isExpanded },
81
+ react_1.default.createElement(core_1.Stack, { gap: 2, mt: 2 }, node.children.map((child) => (react_1.default.createElement(TreeNodeItem, { key: child.id, node: child, level: level + 1, controller: controller, selectedIds: selectedIds, searchQuery: searchQuery, onSelect: onSelect, onAddSubThematic: onAddSubThematic }))))))));
82
+ };
83
+ /**
84
+ * Tree component rendering interactive taxonomic hierarchies with Mantine styling.
85
+ */
86
+ const ThematicTree = ({ controller, onSelect, onAddSubThematic, searchable = true, className = '', style }) => {
87
+ const [tree, setTree] = (0, react_1.useState)(controller.getTree());
88
+ const [selectedIds, setSelectedIds] = (0, react_1.useState)(controller.getSelected());
89
+ const [searchQuery, setSearchQuery] = (0, react_1.useState)('');
90
+ (0, react_1.useEffect)(() => {
91
+ return controller.subscribe((updatedTree, updatedSelected) => {
92
+ setTree(updatedTree);
93
+ setSelectedIds(updatedSelected);
94
+ });
95
+ }, [controller]);
96
+ return (react_1.default.createElement(core_1.Box, { className: `q-thematic-tree ${className}`, style: style },
97
+ searchable && (react_1.default.createElement(core_1.TextInput, { placeholder: "Filtrer les th\u00E9matiques...", size: "xs", mb: "xs", leftSection: react_1.default.createElement(icons_react_1.IconSearch, { size: 14 }), value: searchQuery, onChange: (e) => setSearchQuery(e.currentTarget.value) })),
98
+ react_1.default.createElement(core_1.Stack, { gap: 2 }, tree.length === 0 ? (react_1.default.createElement(core_1.Text, { size: "sm", c: "dimmed", fs: "italic", p: "xs" }, "Aucune th\u00E9matique d\u00E9finie.")) : (tree.map((node) => (react_1.default.createElement(TreeNodeItem, { key: node.id, node: node, level: 0, controller: controller, selectedIds: selectedIds, searchQuery: searchQuery, onSelect: onSelect, onAddSubThematic: onAddSubThematic })))))));
99
+ };
100
+ exports.ThematicTree = ThematicTree;
@@ -0,0 +1,3 @@
1
+ export * from './TaxonomyController';
2
+ export * from './ThematicTree';
3
+ export * from './ThematicBadgeGroup';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./TaxonomyController"), exports);
18
+ __exportStar(require("./ThematicTree"), exports);
19
+ __exportStar(require("./ThematicBadgeGroup"), exports);
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@quatrain/ux-taxonomy",
3
+ "version": "1.0.1",
4
+ "description": "Headless taxonomy and thematic hierarchy controller with React Mantine components",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bun": "src/index.ts",
8
+ "license": "AGPL-3.0-only",
9
+ "files": [
10
+ "LICENSE.md",
11
+ "dist/",
12
+ "src/",
13
+ "README.md"
14
+ ],
15
+ "peerDependencies": {
16
+ "react": "^18.3.1",
17
+ "react-dom": "^18.3.1"
18
+ },
19
+ "dependencies": {
20
+ "@mantine/core": "^7.10.0",
21
+ "@mantine/hooks": "^7.10.0",
22
+ "@quatrain/types": "^1.2.18",
23
+ "@tabler/icons-react": "^3.5.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/jest": "^29.5.12",
27
+ "@types/react": "^18.3.3",
28
+ "@types/react-dom": "^18.3.0",
29
+ "jest": "^29.7.0",
30
+ "react": "^18.3.1",
31
+ "react-dom": "^18.3.1",
32
+ "ts-jest": "^29.4.6",
33
+ "typescript": "^5.2.2"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc",
37
+ "wbuild": "tsc --watch",
38
+ "test": "jest"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/Quatrain/CoreUX.git",
43
+ "directory": "packages/ux-taxonomy"
44
+ }
45
+ }
@@ -0,0 +1,151 @@
1
+ import { TaxonomyController, TaxonomyNode } from './TaxonomyController'
2
+
3
+ describe('TaxonomyController', () => {
4
+ const sampleNodes: TaxonomyNode[] = [
5
+ {
6
+ id: 'soil-health',
7
+ label: 'Soil Health & Biology',
8
+ description: 'Soil microbiology and structure',
9
+ children: [
10
+ {
11
+ id: 'mycorrhizae',
12
+ label: 'Mycorrhizae & Fungi',
13
+ description: 'Symbiotic fungal networks'
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ id: 'cover-crops',
19
+ label: 'Cover Crops & Mulching',
20
+ description: 'Biomass and species selection'
21
+ }
22
+ ]
23
+
24
+ it('initializes correctly with provided nodes and active selection', () => {
25
+ const controller = new TaxonomyController({
26
+ initialNodes: sampleNodes,
27
+ selectedId: 'soil-health'
28
+ })
29
+
30
+ const tree = controller.getTree()
31
+ expect(tree).toHaveLength(2)
32
+ expect(tree[0].id).toBe('soil-health')
33
+ expect(tree[0].children).toHaveLength(1)
34
+ expect(tree[0].children![0].id).toBe('mycorrhizae')
35
+ expect(controller.getSelected()).toEqual(['soil-health'])
36
+ expect(controller.isSelected('soil-health')).toBe(true)
37
+ expect(controller.isSelected('cover-crops')).toBe(false)
38
+ })
39
+
40
+ it('handles single selection mode correctly', () => {
41
+ const controller = new TaxonomyController({ initialNodes: sampleNodes })
42
+ controller.select('soil-health')
43
+ expect(controller.getSelected()).toEqual(['soil-health'])
44
+
45
+ controller.select('cover-crops')
46
+ expect(controller.getSelected()).toEqual(['cover-crops'])
47
+ })
48
+
49
+ it('handles multi-selection mode when configured', () => {
50
+ const controller = new TaxonomyController({
51
+ initialNodes: sampleNodes,
52
+ multiSelect: true
53
+ })
54
+
55
+ controller.select('soil-health')
56
+ controller.select('cover-crops')
57
+ expect(controller.getSelected()).toEqual(['soil-health', 'cover-crops'])
58
+
59
+ controller.deselect('soil-health')
60
+ expect(controller.getSelected()).toEqual(['cover-crops'])
61
+
62
+ controller.toggleSelect('mycorrhizae')
63
+ expect(controller.getSelected()).toEqual(['cover-crops', 'mycorrhizae'])
64
+ controller.toggleSelect('cover-crops')
65
+ expect(controller.getSelected()).toEqual(['mycorrhizae'])
66
+ })
67
+
68
+ it('adds and removes nodes with cascade deletion', () => {
69
+ const controller = new TaxonomyController({ initialNodes: sampleNodes })
70
+
71
+ controller.addNode({
72
+ id: 'irrigation',
73
+ label: 'Irrigation & Water',
74
+ parentId: null
75
+ })
76
+
77
+ expect(controller.getNode('irrigation')).toBeDefined()
78
+ expect(controller.getTree()).toHaveLength(3)
79
+
80
+ // Remove soil-health should also remove mycorrhizae
81
+ controller.removeNode('soil-health')
82
+ expect(controller.getNode('soil-health')).toBeUndefined()
83
+ expect(controller.getNode('mycorrhizae')).toBeUndefined()
84
+ expect(controller.getTree()).toHaveLength(2)
85
+ })
86
+
87
+ it('notifies subscribers upon state change', () => {
88
+ const controller = new TaxonomyController({ initialNodes: sampleNodes })
89
+ const listener = jest.fn()
90
+
91
+ const unsubscribe = controller.subscribe(listener)
92
+ expect(listener).toHaveBeenCalledTimes(1)
93
+
94
+ controller.select('cover-crops')
95
+ expect(listener).toHaveBeenCalledTimes(2)
96
+
97
+ controller.updateCount('cover-crops', 42)
98
+ expect(listener).toHaveBeenCalledTimes(3)
99
+ expect(controller.getNode('cover-crops')?.count).toBe(42)
100
+
101
+ unsubscribe()
102
+ controller.select('soil-health')
103
+ expect(listener).toHaveBeenCalledTimes(3)
104
+ })
105
+
106
+ it('manages expand and collapse state', () => {
107
+ const controller = new TaxonomyController({ initialNodes: sampleNodes })
108
+ expect(controller.isExpanded('soil-health')).toBe(false)
109
+
110
+ controller.toggleExpand('soil-health')
111
+ expect(controller.isExpanded('soil-health')).toBe(true)
112
+
113
+ controller.toggleExpand('soil-health')
114
+ expect(controller.isExpanded('soil-health')).toBe(false)
115
+ })
116
+
117
+ it('manages multi-axial facet filters and matches items', () => {
118
+ const controller = new TaxonomyController({ initialNodes: sampleNodes })
119
+
120
+ controller.setFacetFilter('soils', ['argilo-calcaire', 'limoneux'])
121
+ controller.setFacetFilter('climates', ['mediterraneen'])
122
+
123
+ expect(controller.getFacetFilter('soils')).toEqual(['argilo-calcaire', 'limoneux'])
124
+ expect(controller.getFacetFilter('climates')).toEqual(['mediterraneen'])
125
+ expect(controller.getAllFacetFilters()).toEqual({
126
+ soils: ['argilo-calcaire', 'limoneux'],
127
+ climates: ['mediterraneen']
128
+ })
129
+
130
+ const matchingDoc = {
131
+ category: 'soil-health',
132
+ thematics: ['soil-health'],
133
+ soils: ['argilo-calcaire'],
134
+ climates: ['mediterraneen', 'semi-aride']
135
+ }
136
+
137
+ const nonMatchingDoc = {
138
+ category: 'soil-health',
139
+ thematics: ['soil-health'],
140
+ soils: ['sableux'],
141
+ climates: ['mediterraneen']
142
+ }
143
+
144
+ expect(controller.matchesFilters(matchingDoc)).toBe(true)
145
+ expect(controller.matchesFilters(nonMatchingDoc)).toBe(false)
146
+
147
+ controller.clearFacetFilters()
148
+ expect(controller.getAllFacetFilters()).toEqual({})
149
+ expect(controller.matchesFilters(nonMatchingDoc)).toBe(true)
150
+ })
151
+ })
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Interface representing a node within a taxonomy or thematic hierarchy tree.
3
+ */
4
+ export interface TaxonomyNode {
5
+ /** Unique slug identifier for the thematic node (e.g., 'soil-health') */
6
+ id: string
7
+ /** Human-readable label displayed in the UI */
8
+ label: string
9
+ /** Short description explaining the thematic domain */
10
+ description?: string
11
+ /** Parent thematic identifier, or null for root-level categories */
12
+ parentId?: string | null
13
+ /** Optional icon identifier from Tabler Icons or UI icon registry */
14
+ icon?: string
15
+ /** Accent color token or hex string */
16
+ color?: string
17
+ /** Number of curated documents associated with this thematic */
18
+ count?: number
19
+ /** Child thematic nodes */
20
+ children?: TaxonomyNode[]
21
+ /** Arbitrary domain metadata */
22
+ metadata?: Record<string, any>
23
+ }
24
+
25
+ /**
26
+ * Configuration options for initializing a TaxonomyController instance.
27
+ */
28
+ export interface TaxonomyControllerOptions {
29
+ /** Initial tree or flat list of taxonomy nodes */
30
+ initialNodes?: TaxonomyNode[]
31
+ /** Initially selected thematic identifier */
32
+ selectedId?: string | null
33
+ /** If true, allows multiple simultaneous thematic selections */
34
+ multiSelect?: boolean
35
+ }
36
+
37
+ export type TaxonomyListener = (nodes: TaxonomyNode[], selectedIds: string[]) => void
38
+
39
+ /**
40
+ * Headless, framework-agnostic controller managing hierarchical thematics,
41
+ * category trees, transversal tag taxonomies, and active filter selections.
42
+ */
43
+ export class TaxonomyController {
44
+ protected nodes: Map<string, TaxonomyNode> = new Map()
45
+ protected selectedIds: Set<string> = new Set()
46
+ protected expandedIds: Set<string> = new Set()
47
+ protected facetFilters: Map<string, Set<string>> = new Map()
48
+ protected listeners: Set<TaxonomyListener> = new Set()
49
+ protected multiSelect: boolean
50
+
51
+ /**
52
+ * Instantiates a new TaxonomyController.
53
+ *
54
+ * @param options - Configuration options.
55
+ */
56
+ constructor(options: TaxonomyControllerOptions = {}) {
57
+ this.multiSelect = Boolean(options.multiSelect)
58
+ if (options.initialNodes && options.initialNodes.length > 0) {
59
+ this.loadNodes(options.initialNodes)
60
+ }
61
+ if (options.selectedId) {
62
+ this.selectedIds.add(options.selectedId)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Loads or replaces nodes in the taxonomy manager.
68
+ *
69
+ * @param nodes - Array of hierarchical or flat taxonomy nodes.
70
+ */
71
+ public loadNodes(nodes: TaxonomyNode[]): void {
72
+ this.nodes.clear()
73
+ const flatten = (items: TaxonomyNode[], parentId: string | null = null) => {
74
+ for (const item of items) {
75
+ const node: TaxonomyNode = {
76
+ ...item,
77
+ parentId: item.parentId !== undefined ? item.parentId : parentId,
78
+ children: []
79
+ }
80
+ this.nodes.set(node.id, node)
81
+ if (item.children && item.children.length > 0) {
82
+ flatten(item.children, item.id)
83
+ }
84
+ }
85
+ }
86
+ flatten(nodes)
87
+ this.notify()
88
+ }
89
+
90
+ /**
91
+ * Adds or updates a thematic node in the taxonomy.
92
+ *
93
+ * @param node - The thematic node to insert or update.
94
+ */
95
+ public addNode(node: TaxonomyNode): void {
96
+ this.nodes.set(node.id, {
97
+ ...node,
98
+ children: []
99
+ })
100
+ this.notify()
101
+ }
102
+
103
+ /**
104
+ * Removes a node and recursively removes its sub-thematics.
105
+ *
106
+ * @param id - The identifier of the node to remove.
107
+ */
108
+ public removeNode(id: string): void {
109
+ const childrenToRemove: string[] = []
110
+ const findDescendants = (parentId: string) => {
111
+ for (const [nodeId, node] of this.nodes.entries()) {
112
+ if (node.parentId === parentId) {
113
+ childrenToRemove.push(nodeId)
114
+ findDescendants(nodeId)
115
+ }
116
+ }
117
+ }
118
+ findDescendants(id)
119
+ childrenToRemove.push(id)
120
+
121
+ for (const childId of childrenToRemove) {
122
+ this.nodes.delete(childId)
123
+ this.selectedIds.delete(childId)
124
+ this.expandedIds.delete(childId)
125
+ }
126
+ this.notify()
127
+ }
128
+
129
+ /**
130
+ * Selects a thematic node. Clears previous selection unless multiSelect is enabled.
131
+ *
132
+ * @param id - The node identifier to select.
133
+ */
134
+ public select(id: string): void {
135
+ if (!this.multiSelect) {
136
+ this.selectedIds.clear()
137
+ }
138
+ this.selectedIds.add(id)
139
+ this.notify()
140
+ }
141
+
142
+ /**
143
+ * Deselects a thematic node.
144
+ *
145
+ * @param id - The node identifier to deselect.
146
+ */
147
+ public deselect(id: string): void {
148
+ this.selectedIds.delete(id)
149
+ this.notify()
150
+ }
151
+
152
+ /**
153
+ * Toggles the selection status of a thematic node.
154
+ *
155
+ * @param id - The node identifier to toggle.
156
+ */
157
+ public toggleSelect(id: string): void {
158
+ if (this.selectedIds.has(id)) {
159
+ this.deselect(id)
160
+ } else {
161
+ this.select(id)
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Clears all active selections.
167
+ */
168
+ public clearSelection(): void {
169
+ this.selectedIds.clear()
170
+ this.notify()
171
+ }
172
+
173
+ /**
174
+ * Retrieves the set of currently selected thematic node identifiers.
175
+ *
176
+ * @returns Array of selected IDs.
177
+ */
178
+ public getSelected(): string[] {
179
+ return Array.from(this.selectedIds)
180
+ }
181
+
182
+ /**
183
+ * Checks whether a specific thematic node is currently selected.
184
+ *
185
+ * @param id - The thematic identifier to verify.
186
+ * @returns True if selected, false otherwise.
187
+ */
188
+ public isSelected(id: string): boolean {
189
+ return this.selectedIds.has(id)
190
+ }
191
+
192
+ /**
193
+ * Toggles the expansion collapse state of a parent thematic node.
194
+ *
195
+ * @param id - The thematic identifier to toggle.
196
+ */
197
+ public toggleExpand(id: string): void {
198
+ if (this.expandedIds.has(id)) {
199
+ this.expandedIds.delete(id)
200
+ } else {
201
+ this.expandedIds.add(id)
202
+ }
203
+ this.notify()
204
+ }
205
+
206
+ /**
207
+ * Checks whether a thematic node is currently expanded.
208
+ *
209
+ * @param id - The thematic identifier to check.
210
+ * @returns True if expanded, false otherwise.
211
+ */
212
+ public isExpanded(id: string): boolean {
213
+ return this.expandedIds.has(id)
214
+ }
215
+
216
+ /**
217
+ * Reconstructs and returns the full hierarchical taxonomy tree.
218
+ *
219
+ * @returns Array of root-level TaxonomyNodes with nested children.
220
+ */
221
+ public getTree(): TaxonomyNode[] {
222
+ const rootNodes: TaxonomyNode[] = []
223
+ const nodeMap = new Map<string, TaxonomyNode>()
224
+
225
+ for (const [id, node] of this.nodes.entries()) {
226
+ nodeMap.set(id, { ...node, children: [] })
227
+ }
228
+
229
+ for (const node of nodeMap.values()) {
230
+ if (node.parentId && nodeMap.has(node.parentId)) {
231
+ const parent = nodeMap.get(node.parentId)!
232
+ parent.children = parent.children || []
233
+ parent.children.push(node)
234
+ } else {
235
+ rootNodes.push(node)
236
+ }
237
+ }
238
+
239
+ return rootNodes
240
+ }
241
+
242
+ /**
243
+ * Returns a flat array of all registered taxonomy nodes.
244
+ *
245
+ * @returns Array of TaxonomyNode objects.
246
+ */
247
+ public getFlatNodes(): TaxonomyNode[] {
248
+ return Array.from(this.nodes.values())
249
+ }
250
+
251
+ /**
252
+ * Finds a specific thematic node by identifier.
253
+ *
254
+ * @param id - The node identifier to locate.
255
+ * @returns The TaxonomyNode or undefined if not found.
256
+ */
257
+ public getNode(id: string): TaxonomyNode | undefined {
258
+ return this.nodes.get(id)
259
+ }
260
+
261
+ /**
262
+ * Updates the document counter for a thematic node.
263
+ *
264
+ * @param id - The thematic identifier.
265
+ * @param count - The updated count.
266
+ */
267
+ public updateCount(id: string, count: number): void {
268
+ const existing = this.nodes.get(id)
269
+ if (existing) {
270
+ existing.count = count
271
+ this.notify()
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Subscribes a listener callback to state changes.
277
+ *
278
+ * @param listener - Callback receiving updated nodes and active selection.
279
+ * @returns Unsubscribe function.
280
+ */
281
+ public subscribe(listener: TaxonomyListener): () => void {
282
+ this.listeners.add(listener)
283
+ listener(this.getTree(), this.getSelected())
284
+ return () => {
285
+ this.listeners.delete(listener)
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Sets or updates active filter values for a specific multi-axial facet axis (e.g., 'soils', 'climates').
291
+ *
292
+ * @param axis - The facet axis identifier (e.g. 'soils', 'climates', 'itineraries').
293
+ * @param values - Array of selected facet values.
294
+ */
295
+ public setFacetFilter(axis: string, values: string[]): void {
296
+ if (!values || values.length === 0) {
297
+ this.facetFilters.delete(axis)
298
+ } else {
299
+ this.facetFilters.set(axis, new Set(values))
300
+ }
301
+ this.notify()
302
+ }
303
+
304
+ /**
305
+ * Retrieves active filter values for a specific facet axis.
306
+ *
307
+ * @param axis - The facet axis identifier.
308
+ * @returns Array of active facet values.
309
+ */
310
+ public getFacetFilter(axis: string): string[] {
311
+ const set = this.facetFilters.get(axis)
312
+ return set ? Array.from(set) : []
313
+ }
314
+
315
+ /**
316
+ * Retrieves all active multi-axial facet filters.
317
+ *
318
+ * @returns Key-value map of axis name to selected filter values array.
319
+ */
320
+ public getAllFacetFilters(): Record<string, string[]> {
321
+ const res: Record<string, string[]> = {}
322
+ for (const [axis, set] of this.facetFilters.entries()) {
323
+ if (set.size > 0) {
324
+ res[axis] = Array.from(set)
325
+ }
326
+ }
327
+ return res
328
+ }
329
+
330
+ /**
331
+ * Clears all active multi-axial facet filters.
332
+ */
333
+ public clearFacetFilters(): void {
334
+ this.facetFilters.clear()
335
+ this.notify()
336
+ }
337
+
338
+ /**
339
+ * Tests whether an item's multi-axial facets match the currently active filters.
340
+ *
341
+ * @param itemFacets - Document facet metadata (e.g. { soils: ['argilo-calcaire'], climates: ['mediterraneen'] }).
342
+ * @returns True if the item satisfies all active facet criteria.
343
+ */
344
+ public matchesFilters(itemFacets: Record<string, any>): boolean {
345
+ // 1. Check thematic selection
346
+ if (this.selectedIds.size > 0) {
347
+ const selected = Array.from(this.selectedIds)
348
+ const itemThematics = Array.isArray(itemFacets.thematics) ? itemFacets.thematics : [itemFacets.category].filter(Boolean)
349
+ const matchesThematic = selected.some(s => itemThematics.includes(s) || itemFacets.category === s)
350
+ if (!matchesThematic) return false
351
+ }
352
+
353
+ // 2. Check each multi-axial facet
354
+ for (const [axis, filterSet] of this.facetFilters.entries()) {
355
+ if (filterSet.size === 0) continue
356
+ const itemVals = itemFacets[axis]
357
+ if (!itemVals) return false
358
+ const itemValArray = Array.isArray(itemVals) ? itemVals : [itemVals]
359
+ const hasMatch = itemValArray.some((v: string) => filterSet.has(v))
360
+ if (!hasMatch) return false
361
+ }
362
+
363
+ return true
364
+ }
365
+
366
+ protected notify(): void {
367
+ const tree = this.getTree()
368
+ const selected = this.getSelected()
369
+ for (const listener of this.listeners) {
370
+ listener(tree, selected)
371
+ }
372
+ }
373
+ }