@arbetsformedlingen/yrkesvaljaren 2.0.5
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/LICENSE +190 -0
- package/README.md +369 -0
- package/dist/cjs/af-validation-message-BzY5H0xU.js +16 -0
- package/dist/cjs/core-selector.cjs.entry.js +476 -0
- package/dist/cjs/digi-service-job-selector.cjs.entry.js +1619 -0
- package/dist/cjs/fuse-BTLNKxuL.js +1631 -0
- package/dist/cjs/geo-valjaren.cjs.entry.js +14910 -0
- package/dist/cjs/index-BOuTGlEp.js +1817 -0
- package/dist/cjs/index.cjs.js +14 -0
- package/dist/cjs/job-selector-demo.cjs.entry.js +692 -0
- package/dist/cjs/loader.cjs.js +12 -0
- package/dist/cjs/neobrutal-search.cjs.entry.js +171 -0
- package/dist/cjs/search.engine-D0cMelRi.js +489 -0
- package/dist/cjs/taxonomy-registry-DVmuDUpM.js +435 -0
- package/dist/cjs/types-CtHBl6Ra.js +14 -0
- package/dist/cjs/yrkesvaljaren.cjs.js +24 -0
- package/dist/collection/collection-manifest.json +15 -0
- package/dist/collection/components/core-selector/core-selector.css +803 -0
- package/dist/collection/components/core-selector/core-selector.js +761 -0
- package/dist/collection/components/core-selector/types.js +1 -0
- package/dist/collection/components/design-compat/af-icon.js +13 -0
- package/dist/collection/components/design-compat/af-label.js +2 -0
- package/dist/collection/components/design-compat/af-tag.js +6 -0
- package/dist/collection/components/design-compat/af-validation-message.js +11 -0
- package/dist/collection/components/design-compat/index.js +4 -0
- package/dist/collection/components/geovaljaren/geovaljaren.css +21 -0
- package/dist/collection/components/geovaljaren/geovaljaren.js +304 -0
- package/dist/collection/components/geovaljaren/logic/adapter.utils.js +20 -0
- package/dist/collection/components/geovaljaren/logic/data.utils.js +12 -0
- package/dist/collection/components/geovaljaren/logic/history-service.js +25 -0
- package/dist/collection/components/geovaljaren/logic/search.engine.js +28 -0
- package/dist/collection/components/job-selector/constants.js +26 -0
- package/dist/collection/components/job-selector/history-service.js +41 -0
- package/dist/collection/components/job-selector/job-selector-controller.js +835 -0
- package/dist/collection/components/job-selector/job-selector.css +318 -0
- package/dist/collection/components/job-selector/job-selector.js +1378 -0
- package/dist/collection/components/job-selector/keyboard-handlers.js +84 -0
- package/dist/collection/components/job-selector/logic/adapter.utils.js +75 -0
- package/dist/collection/components/job-selector/logic/label-formatter.js +118 -0
- package/dist/collection/components/job-selector/logic/search.engine.js +50 -0
- package/dist/collection/components/job-selector/search-logic.js +35 -0
- package/dist/collection/components/job-selector/search.utils.js +224 -0
- package/dist/collection/components/job-selector/suggestions-list.js +31 -0
- package/dist/collection/components/job-selector/taxonomy-registry.js +60 -0
- package/dist/collection/components/job-selector/test-data.mock.js +33 -0
- package/dist/collection/components/job-selector/types.js +10 -0
- package/dist/collection/components/job-selector/validation-message.js +24 -0
- package/dist/collection/demo-data.js +20 -0
- package/dist/collection/index.js +7 -0
- package/dist/collection/verkstad/verkstad.js +407 -0
- package/dist/components/core-selector.d.ts +11 -0
- package/dist/components/core-selector.js +1 -0
- package/dist/components/core-selector2.js +1 -0
- package/dist/components/digi-service-job-selector.d.ts +11 -0
- package/dist/components/digi-service-job-selector.js +1 -0
- package/dist/components/fuse.js +1 -0
- package/dist/components/geo-valjaren.d.ts +11 -0
- package/dist/components/geo-valjaren.js +1 -0
- package/dist/components/index.d.ts +35 -0
- package/dist/components/index.js +1 -0
- package/dist/components/job-selector-demo.d.ts +11 -0
- package/dist/components/job-selector-demo.js +1 -0
- package/dist/components/job-selector.js +1 -0
- package/dist/components/neobrutal-search.d.ts +11 -0
- package/dist/components/neobrutal-search.js +1 -0
- package/dist/components/search.engine.js +1 -0
- package/dist/components/types.js +1 -0
- package/dist/esm/af-validation-message-F8NIH2rC.js +14 -0
- package/dist/esm/core-selector.entry.js +474 -0
- package/dist/esm/digi-service-job-selector.entry.js +1617 -0
- package/dist/esm/fuse-BPnhMTul.js +1629 -0
- package/dist/esm/geo-valjaren.entry.js +14908 -0
- package/dist/esm/index-CGXZllj2.js +1806 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/job-selector-demo.entry.js +690 -0
- package/dist/esm/loader.js +10 -0
- package/dist/esm/neobrutal-search.entry.js +169 -0
- package/dist/esm/search.engine-nZ7AcXba.js +479 -0
- package/dist/esm/taxonomy-registry-Dw0VR9Aw.js +430 -0
- package/dist/esm/types-DHUftoZJ.js +12 -0
- package/dist/esm/yrkesvaljaren.js +20 -0
- package/dist/index.cjs.js +1 -0
- package/dist/index.js +1 -0
- package/dist/types/components/core-selector/core-selector.d.ts +46 -0
- package/dist/types/components/core-selector/types.d.ts +1 -0
- package/dist/types/components/design-compat/af-icon.d.ts +7 -0
- package/dist/types/components/design-compat/af-label.d.ts +8 -0
- package/dist/types/components/design-compat/af-tag.d.ts +8 -0
- package/dist/types/components/design-compat/af-validation-message.d.ts +9 -0
- package/dist/types/components/design-compat/index.d.ts +5 -0
- package/dist/types/components/geovaljaren/geovaljaren.d.ts +24 -0
- package/dist/types/components/geovaljaren/logic/adapter.utils.d.ts +7 -0
- package/dist/types/components/geovaljaren/logic/data.utils.d.ts +7 -0
- package/dist/types/components/geovaljaren/logic/history-service.d.ts +6 -0
- package/dist/types/components/geovaljaren/logic/search.engine.d.ts +7 -0
- package/dist/types/components/job-selector/ambient.d.ts +4 -0
- package/dist/types/components/job-selector/constants.d.ts +13 -0
- package/dist/types/components/job-selector/history-service.d.ts +6 -0
- package/dist/types/components/job-selector/job-selector-controller.d.ts +82 -0
- package/dist/types/components/job-selector/job-selector.d.ts +140 -0
- package/dist/types/components/job-selector/keyboard-handlers.d.ts +13 -0
- package/dist/types/components/job-selector/logic/adapter.utils.d.ts +12 -0
- package/dist/types/components/job-selector/logic/label-formatter.d.ts +4 -0
- package/dist/types/components/job-selector/logic/search.engine.d.ts +21 -0
- package/dist/types/components/job-selector/search-logic.d.ts +17 -0
- package/dist/types/components/job-selector/search.utils.d.ts +10 -0
- package/dist/types/components/job-selector/suggestions-list.d.ts +13 -0
- package/dist/types/components/job-selector/taxonomy-registry.d.ts +20 -0
- package/dist/types/components/job-selector/test-data.mock.d.ts +7 -0
- package/dist/types/components/job-selector/types.d.ts +107 -0
- package/dist/types/components/job-selector/validation-message.d.ts +8 -0
- package/dist/types/components/job-selector-demo/job-selector-demo.d.ts +55 -0
- package/dist/types/components/neobrutal-search/neobrutal-search.d.ts +18 -0
- package/dist/types/components.d.ts +425 -0
- package/dist/types/demo-data.d.ts +16 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/stencil-public-runtime.d.ts +1873 -0
- package/dist/types/verkstad/verkstad.d.ts +1 -0
- package/dist/yrkesvaljaren/fuse-B0eHOxow.js.map +1 -0
- package/dist/yrkesvaljaren/index-Cy4BCo5s.js.map +1 -0
- package/dist/yrkesvaljaren/index-DfJXTQqL.js.map +1 -0
- package/dist/yrkesvaljaren/index.esm.js +1 -0
- package/dist/yrkesvaljaren/index.esm.js.map +1 -0
- package/dist/yrkesvaljaren/p-16636162.entry.js +1 -0
- package/dist/yrkesvaljaren/p-273cf6df.entry.js +1 -0
- package/dist/yrkesvaljaren/p-9e48afa4.entry.js +1 -0
- package/dist/yrkesvaljaren/p-BPnhMTul.js +1 -0
- package/dist/yrkesvaljaren/p-CGXZllj2.js +3 -0
- package/dist/yrkesvaljaren/p-DHUftoZJ.js +1 -0
- package/dist/yrkesvaljaren/p-DYFAzvqX.js +1 -0
- package/dist/yrkesvaljaren/p-Dw0VR9Aw.js +1 -0
- package/dist/yrkesvaljaren/p-aef7b5ae.entry.js +1 -0
- package/dist/yrkesvaljaren/p-b0f16abd.entry.js +1 -0
- package/dist/yrkesvaljaren/p-uBrs3sSs.js +1 -0
- package/dist/yrkesvaljaren/search.engine-DPT6roRm.js.map +1 -0
- package/dist/yrkesvaljaren/taxonomy-registry-s5khf6s4.js.map +1 -0
- package/dist/yrkesvaljaren/types-DHUftoZJ.js.map +1 -0
- package/dist/yrkesvaljaren/yrkesvaljaren.css +1 -0
- package/dist/yrkesvaljaren/yrkesvaljaren.esm.js +1 -0
- package/dist/yrkesvaljaren/yrkesvaljaren.esm.js.map +1 -0
- package/loader/cdn.js +1 -0
- package/loader/index.cjs.js +1 -0
- package/loader/index.d.ts +24 -0
- package/loader/index.es2017.js +1 -0
- package/loader/index.js +2 -0
- package/package.json +126 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var fuse = require('./fuse-BTLNKxuL.js');
|
|
4
|
+
var taxonomyRegistry = require('./taxonomy-registry-DVmuDUpM.js');
|
|
5
|
+
|
|
6
|
+
// Minimum number of characters required to trigger search
|
|
7
|
+
const MIN_INPUT_LENGTH = 1;
|
|
8
|
+
// Debounce time for search input (ms)
|
|
9
|
+
const DEBOUNCE_TIME = 100; // ms - Återställt till 250 baserat på sok-box.tsx initialt
|
|
10
|
+
// Default maximum number of results to display
|
|
11
|
+
const MAX_RESULTS = 10;
|
|
12
|
+
// Configuration for Fuse.js
|
|
13
|
+
const FUSE_OPTIONS = {
|
|
14
|
+
keys: ['preferred_label'], // Ändrat från 'label' till 'preferred_label' för att matcha datastrukturen
|
|
15
|
+
includeScore: true,
|
|
16
|
+
ignoreDiacritics: true, // Lägg till för att matcha t.ex. vard -> vård
|
|
17
|
+
ignoreFieldNorm: true, // Lägg till för att ignorera fältlängd vid poängsättning
|
|
18
|
+
threshold: 0.6, // Behåll 0.6
|
|
19
|
+
minMatchCharLength: MIN_INPUT_LENGTH, // Refererar till konstant ovan
|
|
20
|
+
ignoreLocation: true, // Match anywhere in the string
|
|
21
|
+
distance: 100, // Allow matches further apart
|
|
22
|
+
};
|
|
23
|
+
// UI Text Strings
|
|
24
|
+
const PLACEHOLDER_TEXT = "T.ex. frisör, vårdbiträde, snickare"; // Uppdaterad standardplaceholder enligt Figma-design
|
|
25
|
+
|
|
26
|
+
const LabelFormatter = {
|
|
27
|
+
format: (label, query, occupationLabel) => {
|
|
28
|
+
// 1. Highlight the main label based on query
|
|
29
|
+
const labelSegments = highlightText(label, query);
|
|
30
|
+
// 2. Append occupation label if it exists (the "metadata" part)
|
|
31
|
+
if (occupationLabel) {
|
|
32
|
+
return [
|
|
33
|
+
...labelSegments,
|
|
34
|
+
{ text: ' ', isMatch: false },
|
|
35
|
+
{ text: `(${occupationLabel})`, isMetadata: true }
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
return labelSegments;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function calculateEditDistance$1(a, b) {
|
|
42
|
+
const m = a.length;
|
|
43
|
+
const n = b.length;
|
|
44
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
45
|
+
for (let i = 0; i <= m; i++)
|
|
46
|
+
dp[i][0] = i;
|
|
47
|
+
for (let j = 0; j <= n; j++)
|
|
48
|
+
dp[0][j] = j;
|
|
49
|
+
for (let i = 1; i <= m; i++) {
|
|
50
|
+
for (let j = 1; j <= n; j++) {
|
|
51
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
52
|
+
? dp[i - 1][j - 1]
|
|
53
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return dp[m][n];
|
|
57
|
+
}
|
|
58
|
+
function findBestFuzzySubstring$1(text, qToken) {
|
|
59
|
+
const lowerText = text.toLowerCase();
|
|
60
|
+
const targetLen = qToken.length;
|
|
61
|
+
let bestDist = 99;
|
|
62
|
+
let bestStart = -1;
|
|
63
|
+
let bestLen = 0;
|
|
64
|
+
const minLen = Math.max(3, targetLen - 2);
|
|
65
|
+
const maxLen = Math.min(lowerText.length, targetLen + 2);
|
|
66
|
+
for (let start = 0; start <= lowerText.length - minLen; start++) {
|
|
67
|
+
for (let len = minLen; len <= maxLen && start + len <= lowerText.length; len++) {
|
|
68
|
+
const sub = lowerText.slice(start, start + len);
|
|
69
|
+
const dist = calculateEditDistance$1(qToken, sub);
|
|
70
|
+
if (dist < bestDist) {
|
|
71
|
+
bestDist = dist;
|
|
72
|
+
bestStart = start;
|
|
73
|
+
bestLen = len;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const maxAllowedDist = Math.max(2, Math.floor(targetLen * 0.25));
|
|
78
|
+
if (bestDist <= maxAllowedDist && bestStart !== -1) {
|
|
79
|
+
return { start: bestStart, length: bestLen };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Highlights matches in text based on query.
|
|
85
|
+
* Supports multi-word queries.
|
|
86
|
+
*/
|
|
87
|
+
function highlightText(text, query) {
|
|
88
|
+
if (!text)
|
|
89
|
+
return [];
|
|
90
|
+
if (!query || query.trim().length === 0) {
|
|
91
|
+
return [{ text, isMatch: false }];
|
|
92
|
+
}
|
|
93
|
+
const lowerText = text.toLowerCase();
|
|
94
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
|
|
95
|
+
// Set of indices in the original text that should be highlighted
|
|
96
|
+
const highlightedIndices = new Set();
|
|
97
|
+
tokens.forEach(token => {
|
|
98
|
+
let startIndex = 0;
|
|
99
|
+
while ((startIndex = lowerText.indexOf(token, startIndex)) !== -1) {
|
|
100
|
+
for (let i = 0; i < token.length; i++) {
|
|
101
|
+
highlightedIndices.add(startIndex + i);
|
|
102
|
+
}
|
|
103
|
+
startIndex += token.length;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
if (highlightedIndices.size === 0) {
|
|
107
|
+
// Fuzzy highlight fallback: Hitta bästa matchande delsträng i sammansatta ord eller felstavningar
|
|
108
|
+
tokens.forEach(qToken => {
|
|
109
|
+
const match = findBestFuzzySubstring$1(text, qToken);
|
|
110
|
+
if (match) {
|
|
111
|
+
for (let i = 0; i < match.length; i++) {
|
|
112
|
+
highlightedIndices.add(match.start + i);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (highlightedIndices.size === 0) {
|
|
118
|
+
return [{ text, isMatch: false }];
|
|
119
|
+
}
|
|
120
|
+
const segments = [];
|
|
121
|
+
let currentSegmentText = '';
|
|
122
|
+
let currentIsMatch = highlightedIndices.has(0);
|
|
123
|
+
for (let i = 0; i < text.length; i++) {
|
|
124
|
+
const isMatch = highlightedIndices.has(i);
|
|
125
|
+
if (isMatch !== currentIsMatch) {
|
|
126
|
+
// Push previous segment
|
|
127
|
+
if (currentSegmentText.length > 0) {
|
|
128
|
+
segments.push({ text: currentSegmentText, isMatch: currentIsMatch });
|
|
129
|
+
}
|
|
130
|
+
// Start new segment
|
|
131
|
+
currentSegmentText = text[i];
|
|
132
|
+
currentIsMatch = isMatch;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
currentSegmentText += text[i];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Push the last segment
|
|
139
|
+
if (currentSegmentText.length > 0) {
|
|
140
|
+
segments.push({ text: currentSegmentText, isMatch: currentIsMatch });
|
|
141
|
+
}
|
|
142
|
+
return segments;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const MAX_WEAK_FUZZY_RESULTS = 3;
|
|
146
|
+
const MIN_FUZZY_SCORE = 0.4;
|
|
147
|
+
const MAX_FUZZY_SCORE = 0.84;
|
|
148
|
+
{
|
|
149
|
+
taxonomyRegistry.log.setLevel(taxonomyRegistry.log.levels.DEBUG); // Set log level to DEBUG for development/testing
|
|
150
|
+
}
|
|
151
|
+
const normalizeForComparison = (text) => {
|
|
152
|
+
if (!text) {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
const normalized = text.toLowerCase().trim();
|
|
156
|
+
return normalized;
|
|
157
|
+
};
|
|
158
|
+
function formatDisplayLabel(item) {
|
|
159
|
+
if (item.occupation_name_preferred_label) {
|
|
160
|
+
return `${item.preferred_label || ''} (${item.occupation_name_preferred_label})`;
|
|
161
|
+
}
|
|
162
|
+
return item.preferred_label || '';
|
|
163
|
+
}
|
|
164
|
+
function sortResultsStrict(results) {
|
|
165
|
+
return results.sort((a, b) => {
|
|
166
|
+
const scoreA = a.score ?? 0;
|
|
167
|
+
const scoreB = b.score ?? 0;
|
|
168
|
+
if (scoreA !== scoreB) {
|
|
169
|
+
return scoreB - scoreA;
|
|
170
|
+
}
|
|
171
|
+
const weightA = a.weight ?? 0;
|
|
172
|
+
const weightB = b.weight ?? 0;
|
|
173
|
+
if (weightA !== weightB) {
|
|
174
|
+
// Buggen är fixad här
|
|
175
|
+
return weightB - weightA;
|
|
176
|
+
}
|
|
177
|
+
return a.displayLabel.localeCompare(b.displayLabel, 'sv');
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function segmentsToHtml(segments) {
|
|
181
|
+
return segments.map(seg => {
|
|
182
|
+
if (seg.isMatch) {
|
|
183
|
+
return `<strong>${seg.text}</strong>`;
|
|
184
|
+
}
|
|
185
|
+
if (seg.isMetadata) {
|
|
186
|
+
return `<span class="occupation-variant-label">${seg.text}</span>`;
|
|
187
|
+
}
|
|
188
|
+
return seg.text;
|
|
189
|
+
}).join('');
|
|
190
|
+
}
|
|
191
|
+
// Keep export for controller compatibility (it was used there)
|
|
192
|
+
// But implement using new logic or just re-export if we want to keep it simple?
|
|
193
|
+
// The controller passes HTML to it.
|
|
194
|
+
// Actually, `styleParentheticalText` took a string (HTML) and did regex replacement.
|
|
195
|
+
// If the controller uses it on `item.displayLabel` (which is plain text usually, or mapped),
|
|
196
|
+
// the controller does: `const historyItem = { ...item, highlightedLabel: styleParentheticalText(item.displayLabel) };`
|
|
197
|
+
// `displayLabel` for "Frontend (System)" is "Frontend (System)".
|
|
198
|
+
// If we run that through `segmentsToHtml(LabelFormatter.format(label, ''))`, we get the same result!
|
|
199
|
+
// So let's re-implement `styleParentheticalText` using `LabelFormatter`.
|
|
200
|
+
const styleParentheticalText = (text) => {
|
|
201
|
+
if (!text)
|
|
202
|
+
return '';
|
|
203
|
+
return text.replace(/ (\([^)]+\))/g, ' <span class="occupation-variant-label">$1</span>');
|
|
204
|
+
};
|
|
205
|
+
function getDirectScore(normalizedLabel, normalizedQuery, searchTokens) {
|
|
206
|
+
if (normalizedLabel === normalizedQuery) {
|
|
207
|
+
return 1.0;
|
|
208
|
+
}
|
|
209
|
+
if (searchTokens.length > 1) {
|
|
210
|
+
return searchTokens.every(token => normalizedLabel.includes(token)) ? 0.98 : null;
|
|
211
|
+
}
|
|
212
|
+
if (normalizedLabel.startsWith(normalizedQuery)) {
|
|
213
|
+
return 0.99;
|
|
214
|
+
}
|
|
215
|
+
if (normalizedLabel.includes(normalizedQuery)) {
|
|
216
|
+
return 0.95;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
function calculateEditDistance(a, b) {
|
|
221
|
+
const m = a.length;
|
|
222
|
+
const n = b.length;
|
|
223
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
224
|
+
for (let i = 0; i <= m; i++)
|
|
225
|
+
dp[i][0] = i;
|
|
226
|
+
for (let j = 0; j <= n; j++)
|
|
227
|
+
dp[0][j] = j;
|
|
228
|
+
for (let i = 1; i <= m; i++) {
|
|
229
|
+
for (let j = 1; j <= n; j++) {
|
|
230
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
231
|
+
? dp[i - 1][j - 1]
|
|
232
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return dp[m][n];
|
|
236
|
+
}
|
|
237
|
+
function findBestFuzzySubstring(text, qToken) {
|
|
238
|
+
const lowerText = text.toLowerCase();
|
|
239
|
+
const targetLen = qToken.length;
|
|
240
|
+
let bestDist = 99;
|
|
241
|
+
const minLen = Math.max(3, targetLen - 2);
|
|
242
|
+
const maxLen = Math.min(lowerText.length, targetLen + 2);
|
|
243
|
+
for (let start = 0; start <= lowerText.length - minLen; start++) {
|
|
244
|
+
for (let len = minLen; len <= maxLen && start + len <= lowerText.length; len++) {
|
|
245
|
+
const sub = lowerText.slice(start, start + len);
|
|
246
|
+
const dist = calculateEditDistance(qToken, sub);
|
|
247
|
+
if (dist < bestDist) {
|
|
248
|
+
bestDist = dist;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const maxAllowedDist = Math.max(2, Math.floor(targetLen * 0.25));
|
|
253
|
+
return bestDist <= maxAllowedDist ? bestDist : null;
|
|
254
|
+
}
|
|
255
|
+
function isSingleWordOrCompoundRoot(query, label) {
|
|
256
|
+
const normQ = query.toLowerCase().trim();
|
|
257
|
+
const normL = label.toLowerCase().trim();
|
|
258
|
+
if (normL.includes(' ')) {
|
|
259
|
+
const dist = calculateEditDistance(normQ, normL);
|
|
260
|
+
return dist <= Math.max(2, Math.floor(normQ.length * 0.25)) && Math.abs(normQ.length - normL.length) <= 2;
|
|
261
|
+
}
|
|
262
|
+
const subDist = findBestFuzzySubstring(normL, normQ);
|
|
263
|
+
return subDist !== null;
|
|
264
|
+
}
|
|
265
|
+
function createSearchResult(item, query, score) {
|
|
266
|
+
const displayLabel = formatDisplayLabel(item);
|
|
267
|
+
const displaySegments = LabelFormatter.format(item.preferred_label || '', query, item.occupation_name_preferred_label);
|
|
268
|
+
return {
|
|
269
|
+
id: item.id,
|
|
270
|
+
type: item.type,
|
|
271
|
+
preferred_label: item.preferred_label || '',
|
|
272
|
+
rawLabel: item.preferred_label || '',
|
|
273
|
+
displayLabel,
|
|
274
|
+
highlightedLabel: segmentsToHtml(displaySegments),
|
|
275
|
+
displaySegments,
|
|
276
|
+
weight: item.weight ?? 0,
|
|
277
|
+
score,
|
|
278
|
+
occupationId: item.occupation_name_id,
|
|
279
|
+
occupationLabel: item.occupation_name_preferred_label,
|
|
280
|
+
ssyk_level_4_id: item.ssyk_level_4_id,
|
|
281
|
+
ssyk_code_2012: item.ssyk_code_2012,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function searchConcepts(query, items, fuseInstance, maxResults) {
|
|
285
|
+
if (!fuseInstance) {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
if (!items || !Array.isArray(items)) {
|
|
289
|
+
taxonomyRegistry.log.warn('[searchConcepts] Ogiltig items array.');
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
if (!query || query.length < MIN_INPUT_LENGTH) {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const normalizedQuery = normalizeForComparison(query);
|
|
296
|
+
const searchTokens = normalizedQuery.split(' ').filter(t => t.length > 0);
|
|
297
|
+
const directResults = [];
|
|
298
|
+
const processedIds = new Set();
|
|
299
|
+
for (const item of items) {
|
|
300
|
+
const normalizedLabel = normalizeForComparison(item.preferred_label);
|
|
301
|
+
const score = getDirectScore(normalizedLabel, normalizedQuery, searchTokens);
|
|
302
|
+
if (score !== null) {
|
|
303
|
+
directResults.push(createSearchResult(item, query, score));
|
|
304
|
+
processedIds.add(item.id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (directResults.length > 0) {
|
|
308
|
+
return sortResultsStrict(directResults).slice(0, maxResults);
|
|
309
|
+
}
|
|
310
|
+
// Fuzzy är ren fallback när inga direkta träffar finns.
|
|
311
|
+
if (fuseInstance) {
|
|
312
|
+
const fuseSearchOutput = fuseInstance.search(normalizedQuery);
|
|
313
|
+
if (fuseSearchOutput.length === 0) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
const bestRawScore = fuseSearchOutput[0].score ?? 1.0;
|
|
317
|
+
const candidateResults = [];
|
|
318
|
+
let hasStrongRootMatch = false;
|
|
319
|
+
for (const fr of fuseSearchOutput) {
|
|
320
|
+
if (processedIds.has(fr.item.id)) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const rawScore = fr.score ?? 1.0;
|
|
324
|
+
const baseFuzzyScore = 1 - rawScore;
|
|
325
|
+
if (baseFuzzyScore < MIN_FUZZY_SCORE) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
// Strikt gap-filter: Tillåt aldrig träffar som har mer än 0.12 sämre råpoäng än bästa träffen
|
|
329
|
+
if (rawScore > bestRawScore + 0.12) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const label = normalizeForComparison(fr.item.preferred_label);
|
|
333
|
+
const weight = fr.item.weight ?? 0;
|
|
334
|
+
// Hitta om etiketten innehåller en rotterm eller sammansatt del som matchar sökningen
|
|
335
|
+
const isRootMatch = isSingleWordOrCompoundRoot(normalizedQuery, label);
|
|
336
|
+
if (isRootMatch && rawScore <= 0.25) {
|
|
337
|
+
hasStrongRootMatch = true;
|
|
338
|
+
}
|
|
339
|
+
let score = baseFuzzyScore * 0.70 + (weight * 0.10);
|
|
340
|
+
if (isRootMatch) {
|
|
341
|
+
score += 0.12;
|
|
342
|
+
}
|
|
343
|
+
const boundedScore = Math.min(MAX_FUZZY_SCORE, Math.max(MIN_FUZZY_SCORE, score));
|
|
344
|
+
candidateResults.push(createSearchResult(fr.item, query, boundedScore));
|
|
345
|
+
processedIds.add(fr.item.id);
|
|
346
|
+
}
|
|
347
|
+
const sortedFuzzy = sortResultsStrict(candidateResults);
|
|
348
|
+
if (sortedFuzzy.length === 0) {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
// Dynamiskt maxantal vid fuzzy-sökningar:
|
|
352
|
+
// - Stark rotmatchning (t.ex. injenjör, barlnmorska): Visa upp till 5 relevanta förslag så att fritextfältet syns utan att skrolla.
|
|
353
|
+
// - Svag/osäker matchning (t.ex. böla fölkja, pensionär): Visa MAX 3 träffar.
|
|
354
|
+
const limit = hasStrongRootMatch ? Math.min(5, maxResults) : Math.min(MAX_WEAK_FUZZY_RESULTS, maxResults);
|
|
355
|
+
return sortedFuzzy.slice(0, limit);
|
|
356
|
+
}
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const TaxonomyAdapter = {
|
|
361
|
+
/** Maps a raw TaxonomyItem to a GenericItem for the CoreSelector */
|
|
362
|
+
toGenericItem(item) {
|
|
363
|
+
return {
|
|
364
|
+
id: item.id,
|
|
365
|
+
label: item.preferred_label || '',
|
|
366
|
+
description: item.occupation_name_preferred_label,
|
|
367
|
+
metadata: {
|
|
368
|
+
original: item,
|
|
369
|
+
type: item.type,
|
|
370
|
+
ssyk_level_4_id: item.ssyk_level_4_id,
|
|
371
|
+
weight: item.weight
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
/** Maps a SearchResult (from our engine) to a GenericItem */
|
|
376
|
+
searchResultToGenericItem(result, query) {
|
|
377
|
+
const segments = LabelFormatter.format(result.preferred_label || '', query, result.occupation_name_preferred_label);
|
|
378
|
+
return {
|
|
379
|
+
id: result.id,
|
|
380
|
+
label: result.preferred_label || '',
|
|
381
|
+
displaySegments: segments,
|
|
382
|
+
highlightedLabel: segmentsToHtml(segments),
|
|
383
|
+
metadata: {
|
|
384
|
+
original: result,
|
|
385
|
+
ssyk_level_4_id: result.ssyk_level_4_id,
|
|
386
|
+
weight: result.weight
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
/** Maps a GenericItem back to our internal SearchResult format */
|
|
391
|
+
fromGenericItem(item) {
|
|
392
|
+
return {
|
|
393
|
+
id: item.id,
|
|
394
|
+
preferred_label: item.label,
|
|
395
|
+
weight: item.metadata?.weight,
|
|
396
|
+
...item.metadata?.original
|
|
397
|
+
};
|
|
398
|
+
},
|
|
399
|
+
/** Maps an internal item to the public event format */
|
|
400
|
+
toPublicSelection(item) {
|
|
401
|
+
const id = item.id || '';
|
|
402
|
+
const preferredLabel = item.preferred_label || item.rawLabel || '';
|
|
403
|
+
if (!id) {
|
|
404
|
+
return {
|
|
405
|
+
type: 'manual-input',
|
|
406
|
+
id: null,
|
|
407
|
+
preferred_label: preferredLabel,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (item.type !== 'job-title' && item.type !== 'occupation-name') {
|
|
411
|
+
throw new Error(`Ogiltig intern yrkestyp för ${id}: ${item.type}`);
|
|
412
|
+
}
|
|
413
|
+
const selection = {
|
|
414
|
+
type: item.type,
|
|
415
|
+
id,
|
|
416
|
+
preferred_label: preferredLabel,
|
|
417
|
+
};
|
|
418
|
+
if (item.ssyk_code_2012) {
|
|
419
|
+
selection.ssyk_code_2012 = item.ssyk_code_2012;
|
|
420
|
+
}
|
|
421
|
+
const occupationId = item.occupation_name_id || item.occupationId;
|
|
422
|
+
const occupationLabel = item.occupation_name_preferred_label || item.occupationLabel;
|
|
423
|
+
if (item.type === 'job-title' && occupationId && occupationLabel) {
|
|
424
|
+
selection.related = {
|
|
425
|
+
type: 'occupation-name',
|
|
426
|
+
id: occupationId,
|
|
427
|
+
preferred_label: occupationLabel,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return selection;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
class TaxonomySearchEngine {
|
|
435
|
+
fuseInstance = null;
|
|
436
|
+
items = [];
|
|
437
|
+
constructor(data) {
|
|
438
|
+
this.items = data;
|
|
439
|
+
this.fuseInstance = new fuse.entry_default(data, FUSE_OPTIONS);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Public API: Performs a search and returns clean, official domain objects.
|
|
443
|
+
* This is what external consumers (headless) should use.
|
|
444
|
+
*/
|
|
445
|
+
search(query, maxResults = 10) {
|
|
446
|
+
if (!this.fuseInstance)
|
|
447
|
+
return [];
|
|
448
|
+
const results = searchConcepts(query, this.items, this.fuseInstance, maxResults);
|
|
449
|
+
// Map directly to public selection objects
|
|
450
|
+
return results.map(result => TaxonomyAdapter.toPublicSelection(result));
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Internal API: Performs a search and returns GenericItems for the CoreSelector UI ("Bucky").
|
|
454
|
+
* Includes highlighting, grouping and internal metadata.
|
|
455
|
+
*/
|
|
456
|
+
searchAsGeneric(query, maxResults = 10) {
|
|
457
|
+
if (!this.fuseInstance)
|
|
458
|
+
return [];
|
|
459
|
+
// Use the existing robust search function
|
|
460
|
+
const results = searchConcepts(query, this.items, this.fuseInstance, maxResults);
|
|
461
|
+
// Adapt SearchResult -> GenericItem
|
|
462
|
+
return results.map(result => ({
|
|
463
|
+
id: result.id,
|
|
464
|
+
label: result.displayLabel, // Use the formatted label (e.g. "Nurse (Healthcare)")
|
|
465
|
+
description: result.occupationLabel,
|
|
466
|
+
group: result.type === 'job-title' ? 'Yrke' : 'Yrkesgrupp',
|
|
467
|
+
metadata: {
|
|
468
|
+
originalResult: result, // Keep full original result for consumers
|
|
469
|
+
highlightedLabel: result.highlightedLabel,
|
|
470
|
+
score: result.score,
|
|
471
|
+
weight: result.weight
|
|
472
|
+
}
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
updateData(newData) {
|
|
476
|
+
this.items = newData;
|
|
477
|
+
this.fuseInstance = new fuse.entry_default(newData, FUSE_OPTIONS);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
exports.DEBOUNCE_TIME = DEBOUNCE_TIME;
|
|
482
|
+
exports.LabelFormatter = LabelFormatter;
|
|
483
|
+
exports.MAX_RESULTS = MAX_RESULTS;
|
|
484
|
+
exports.MIN_INPUT_LENGTH = MIN_INPUT_LENGTH;
|
|
485
|
+
exports.PLACEHOLDER_TEXT = PLACEHOLDER_TEXT;
|
|
486
|
+
exports.TaxonomyAdapter = TaxonomyAdapter;
|
|
487
|
+
exports.TaxonomySearchEngine = TaxonomySearchEngine;
|
|
488
|
+
exports.segmentsToHtml = segmentsToHtml;
|
|
489
|
+
exports.styleParentheticalText = styleParentheticalText;
|