@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.
- package/README.md +41 -0
- package/dist/TaxonomyController.d.ts +184 -0
- package/dist/TaxonomyController.js +318 -0
- package/dist/ThematicBadgeGroup.d.ts +22 -0
- package/dist/ThematicBadgeGroup.js +29 -0
- package/dist/ThematicTree.d.ts +23 -0
- package/dist/ThematicTree.js +100 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -0
- package/package.json +45 -0
- package/src/TaxonomyController.test.ts +151 -0
- package/src/TaxonomyController.ts +373 -0
- package/src/ThematicBadgeGroup.tsx +73 -0
- package/src/ThematicTree.tsx +230 -0
- package/src/index.ts +3 -0
|
@@ -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;
|
package/dist/index.d.ts
ADDED
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
|
+
}
|