@platforma-sdk/model 1.68.5 → 1.68.7

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.
Files changed (52) hide show
  1. package/dist/columns/column_collection_builder.cjs +8 -2
  2. package/dist/columns/column_collection_builder.cjs.map +1 -1
  3. package/dist/columns/column_collection_builder.d.ts +14 -3
  4. package/dist/columns/column_collection_builder.d.ts.map +1 -1
  5. package/dist/columns/column_collection_builder.js +8 -2
  6. package/dist/columns/column_collection_builder.js.map +1 -1
  7. package/dist/columns/ctx_column_sources.d.ts +1 -1
  8. package/dist/columns/index.d.ts +1 -1
  9. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs +50 -50
  10. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
  11. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts +5 -10
  12. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts.map +1 -1
  13. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js +50 -50
  14. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
  15. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs +16 -17
  16. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs.map +1 -1
  17. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts +4 -4
  18. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts.map +1 -1
  19. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js +16 -17
  20. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js.map +1 -1
  21. package/dist/components/PlDataTable/createPlDataTable/utils.cjs +8 -2
  22. package/dist/components/PlDataTable/createPlDataTable/utils.cjs.map +1 -1
  23. package/dist/components/PlDataTable/createPlDataTable/utils.js +8 -2
  24. package/dist/components/PlDataTable/createPlDataTable/utils.js.map +1 -1
  25. package/dist/components/PlDatasetSelector/filter_discovery.d.ts +1 -1
  26. package/dist/index.d.ts +6 -6
  27. package/dist/labels/derive_distinct_labels.cjs +121 -50
  28. package/dist/labels/derive_distinct_labels.cjs.map +1 -1
  29. package/dist/labels/derive_distinct_labels.d.ts +30 -14
  30. package/dist/labels/derive_distinct_labels.d.ts.map +1 -1
  31. package/dist/labels/derive_distinct_labels.js +121 -50
  32. package/dist/labels/derive_distinct_labels.js.map +1 -1
  33. package/dist/labels/derive_distinct_tooltips.cjs +0 -10
  34. package/dist/labels/derive_distinct_tooltips.cjs.map +1 -1
  35. package/dist/labels/derive_distinct_tooltips.d.ts +2 -3
  36. package/dist/labels/derive_distinct_tooltips.d.ts.map +1 -1
  37. package/dist/labels/derive_distinct_tooltips.js +0 -10
  38. package/dist/labels/derive_distinct_tooltips.js.map +1 -1
  39. package/dist/labels/index.d.ts +1 -1
  40. package/dist/package.cjs +1 -1
  41. package/dist/package.js +1 -1
  42. package/package.json +4 -4
  43. package/src/columns/column_collection_builder.test.ts +0 -2
  44. package/src/columns/column_collection_builder.ts +26 -3
  45. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +90 -75
  46. package/src/components/PlDataTable/createPlDataTable/discoverColumns.ts +31 -34
  47. package/src/components/PlDataTable/createPlDataTable/utils.test.ts +1 -1
  48. package/src/components/PlDataTable/createPlDataTable/utils.ts +11 -4
  49. package/src/labels/derive_distinct_labels.test.ts +396 -52
  50. package/src/labels/derive_distinct_labels.ts +205 -103
  51. package/src/labels/derive_distinct_tooltips.test.ts +1 -22
  52. package/src/labels/derive_distinct_tooltips.ts +1 -18
@@ -2,18 +2,33 @@ import {
2
2
  Annotation,
3
3
  parseJson,
4
4
  readAnnotation,
5
+ type AxisQualification,
6
+ type PObjectId,
5
7
  type PObjectSpec,
6
8
  type StringifiedJson,
7
9
  type Trace,
8
10
  } from "@milaboratories/pl-model-common";
9
11
  import { throwError } from "@milaboratories/helpers";
10
12
  import { isFunction, isNil } from "es-toolkit";
13
+ import type { MatchQualifications } from "../columns/column_collection_builder";
11
14
 
12
15
  export type { Trace, TraceEntry } from "@milaboratories/pl-model-common";
13
16
 
14
17
  const DISTANCE_PENALTY = 0.001;
15
18
  const LABEL_TYPE = "__LABEL__";
16
19
  const LABEL_TYPE_FULL = "__LABEL__@1";
20
+ const LINKER_TYPE = "__LINKER__";
21
+ const LINKER_TYPE_FULL = "__LINKER__@1";
22
+ const HIT_QUAL_TYPE = "__HIT_QUAL__";
23
+ const ANCHOR_QUAL_TYPE_PREFIX = "__ANCHOR_QUAL__:";
24
+
25
+ function isAnchorQualType(t: string): boolean {
26
+ return t.startsWith(ANCHOR_QUAL_TYPE_PREFIX);
27
+ }
28
+
29
+ function isSyntheticType(t: string): boolean {
30
+ return t === LINKER_TYPE || t === HIT_QUAL_TYPE || isAnchorQualType(t);
31
+ }
17
32
 
18
33
  /** SDK-internal trace shape — adds fields used by this algorithm only, not part of the on-disk contract. */
19
34
  type ExtendedTraceEntry = Trace[number] & {
@@ -21,33 +36,66 @@ type ExtendedTraceEntry = Trace[number] & {
21
36
  position?: "prefix" | "suffix";
22
37
  };
23
38
 
39
+ export type LinkerStep = {
40
+ spec: PObjectSpec;
41
+ qualifications?: AxisQualification[];
42
+ };
43
+
24
44
  export type Entry =
25
45
  | PObjectSpec
26
46
  | {
27
47
  spec: PObjectSpec;
28
48
  /** Extra trace entries merged with the base trace from annotations. */
29
49
  extraTrace?: ExtendedTraceEntry[];
30
- /** Linker steps traversed to discover this column; used to append "via $linkLabel" to derived labels. */
31
- linkerPath?: { spec: PObjectSpec }[];
50
+ /** Linker steps traversed to discover this column; rendered as "via " only when needed for uniqueness. */
51
+ linkerPath?: LinkerStep[];
52
+ /** Axis qualifications applied to the hit column / already-bound anchors; rendered as "[…]" suffixes. */
53
+ qualifications?: MatchQualifications;
32
54
  };
33
55
 
56
+ /**
57
+ * Per-zone formatters. Each one receives raw inputs and returns the rendered text for that zone,
58
+ * or `undefined` to suppress the zone entirely (no synthetic injection → no minimization, no render).
59
+ */
60
+ export type DeriveLabelsFormatters = {
61
+ /** Native column label. Default: identity. `undefined` → label entry not added (treated as if spec had no label). */
62
+ native?: (label: string, spec: PObjectSpec, index: number) => string | undefined;
63
+ /** Linker zone (whole "via …" piece). Receives step labels with step-quals already inlined.
64
+ * Default: `via ${steps.join(" > ")}`. */
65
+ linker?: (linkerLabels: string[], spec: PObjectSpec, index: number) => string | undefined;
66
+ /** Per-step linker qualifications inlined into the step base label.
67
+ * Default: `[${formatQualifications(qs)}]`. `undefined` → step rendered without quals. */
68
+ linkerStepQualification?: (
69
+ qualifications: AxisQualification[],
70
+ stepIndex: number,
71
+ stepSpec: PObjectSpec,
72
+ ) => string | undefined;
73
+ /** Hit-axis qualifications block. Default: `[${formatQualifications(qs)}]`. */
74
+ hitQualification?: (
75
+ qualifications: AxisQualification[],
76
+ spec: PObjectSpec,
77
+ index: number,
78
+ ) => string | undefined;
79
+ /** Per-anchor qualifications block. Default: `[${anchorId}: ${formatQualifications(qs)}]`. */
80
+ anchorQualification?: (
81
+ anchorId: PObjectId,
82
+ qualifications: AxisQualification[],
83
+ spec: PObjectSpec,
84
+ index: number,
85
+ ) => string | undefined;
86
+ };
87
+
34
88
  export type DeriveLabelsOptions = {
35
- /** Separator to use between label parts (" / " by default) */
89
+ /** Separator to use between label parts (" / " by default). */
36
90
  separator?: string;
37
- /** If true, label will be added as suffix (at the end of the generated label). By default label added as a prefix. */
91
+ /** If true, native label is appended at the end of the trace zone. By default it is prepended (label is the most important name). */
38
92
  addLabelAsSuffix?: boolean;
39
- /** Force inclusion of native column label */
93
+ /** Force inclusion of native column label even when not needed for uniqueness. */
40
94
  includeNativeLabel?: boolean;
41
- /** Trace elements list that will be forced to be included in the label. */
95
+ /** Trace types that must be included in the label. */
42
96
  forceTraceElements?: string[];
43
- /** Custom formatter for linker path suffix. Receives the array of linker labels from the full traversal chain,
44
- * the column spec, and the column index.
45
- * If returns undefined, no linker suffix is appended. By default labels are joined with " > " and prefixed with "via ". */
46
- linkerLabelFormatter?: (
47
- linkerLabels: string[],
48
- spec: PObjectSpec,
49
- index: number,
50
- ) => string | undefined;
97
+ /** Per-zone custom formatters. Returning `undefined` from any formatter suppresses the corresponding zone. */
98
+ formatters?: DeriveLabelsFormatters;
51
99
  };
52
100
 
53
101
  export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptions = {}): string[] {
@@ -57,23 +105,21 @@ export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptio
57
105
  : undefined;
58
106
  const separator = options.separator ?? " / ";
59
107
 
60
- // Collect per-entry linker suffixes before disambiguation
61
- const linkerSuffixes = values.map((v, i) => {
62
- const spec = "spec" in v && typeof v.spec === "object" ? v.spec : (v as PObjectSpec);
63
- const linkerLabels = extractLinkerLabels(v);
64
- if (linkerLabels.length === 0) return undefined;
65
- return isFunction(options.linkerLabelFormatter)
66
- ? options.linkerLabelFormatter(linkerLabels, spec, i)
67
- : `via ${linkerLabels.join(" > ")}`;
68
- });
108
+ const records = values.map((v, i) => enrichRecord(v, i, options));
109
+ const stats = collectTypeStats(records);
69
110
 
70
- // Phase 1: enrich each value with parsed trace
71
- const records = values.map((v) => enrichRecord(v, options));
111
+ const hasAnySynthetic = records.some((r) => r.fullTrace.some((ft) => isSyntheticType(ft.type)));
112
+ const labelForced =
113
+ (options.includeNativeLabel === true || hasAnySynthetic) &&
114
+ stats.countByType.has(LABEL_TYPE_FULL);
115
+ // Tied to labeled-step presence, not path presence: entries with a non-empty linkerPath
116
+ // but no labeled steps contribute no LINKER_TYPE trace entry, so they do not count here.
117
+ const linkerForced = stats.countByType.get(LINKER_TYPE_FULL) === values.length;
72
118
 
73
- // Phase 2: collect global type statistics
74
- const stats = collectTypeStats(records);
119
+ const forcedSet = new Set<string>();
120
+ if (labelForced) forcedSet.add(LABEL_TYPE_FULL);
121
+ if (linkerForced) forcedSet.add(LINKER_TYPE_FULL);
75
122
 
76
- // Phase 3: classify types into main (present everywhere) and secondary
77
123
  const { mainTypes, secondaryTypes } = classifyTypes(stats, values.length);
78
124
 
79
125
  const build = (typeSet: Set<string>, force: boolean) =>
@@ -83,28 +129,16 @@ export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptio
83
129
  if (secondaryTypes.length !== 0)
84
130
  throw new Error("Non-empty secondary types list while main types list is empty.");
85
131
 
86
- return applyLinkerSuffixes(
87
- build(new Set(LABEL_TYPE_FULL), true) ??
88
- throwError("Failed to derive labels using native column labels"),
89
- linkerSuffixes,
132
+ return (
133
+ build(new Set([LABEL_TYPE_FULL]), true) ??
134
+ throwError("Failed to derive labels using native column labels")
90
135
  );
91
136
  }
92
137
 
93
- // Phase 4: search for minimal type set that produces unique labels
94
- //
95
- // includedCount = 2
96
- // * *
97
- // T0 T1 T2 T3 T4 T5
98
- // *
99
- // additionalType = 3
100
- //
101
- // Resulting set: T0, T1, T3
102
- //
103
138
  let includedCount = 0;
104
139
  let additionalType = -1;
105
140
  while (includedCount < mainTypes.length) {
106
- const currentSet = new Set<string>();
107
- if (options.includeNativeLabel) currentSet.add(LABEL_TYPE_FULL);
141
+ const currentSet = new Set<string>(forcedSet);
108
142
  for (let i = 0; i < includedCount; ++i) currentSet.add(mainTypes[i]);
109
143
  if (additionalType >= 0) currentSet.add(mainTypes[additionalType]);
110
144
 
@@ -115,13 +149,10 @@ export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptio
115
149
  records,
116
150
  stats,
117
151
  forceTraceElements,
118
- options,
152
+ forcedSet,
119
153
  separator,
120
154
  );
121
- return applyLinkerSuffixes(
122
- build(minimized, false) ?? throwError("Failed to derive unique labels"),
123
- linkerSuffixes,
124
- );
155
+ return build(minimized, false) ?? throwError("Failed to derive unique labels");
125
156
  }
126
157
 
127
158
  additionalType++;
@@ -131,42 +162,16 @@ export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptio
131
162
  }
132
163
  }
133
164
 
134
- // Fallback: include all types, then minimize
135
- const fallbackSet = new Set([...mainTypes, ...secondaryTypes]);
165
+ const fallbackSet = new Set([...forcedSet, ...mainTypes, ...secondaryTypes]);
136
166
  const minimized = minimizeTypeSet(
137
167
  fallbackSet,
138
168
  records,
139
169
  stats,
140
170
  forceTraceElements,
141
- options,
171
+ forcedSet,
142
172
  separator,
143
173
  );
144
- return applyLinkerSuffixes(
145
- build(minimized, true) ?? throwError("Failed to derive unique labels"),
146
- linkerSuffixes,
147
- );
148
- }
149
-
150
- /** Apply pre-formatted linker suffixes to labels that have them. */
151
- function applyLinkerSuffixes(labels: string[], suffixes: (string | undefined)[]): string[] {
152
- return labels.map((label, i) => (isNil(suffixes[i]) ? label : `${label} ${suffixes[i]}`));
153
- }
154
-
155
- /** Extract linker labels from every step of the linkers path. */
156
- function extractLinkerLabels(entry: Entry): string[] {
157
- if (!("spec" in entry) || typeof entry.spec !== "object") return [];
158
- const path = entry.linkerPath;
159
- if (path === undefined || path.length === 0) return [];
160
- const labels: string[] = [];
161
- for (const step of path) {
162
- const label = (
163
- readAnnotation(step.spec, Annotation.LinkLabel) ?? readAnnotation(step.spec, Annotation.Label)
164
- )?.trim();
165
- if (label !== undefined && label.length > 0) {
166
- labels.push(label);
167
- }
168
- }
169
- return labels;
174
+ return build(minimized, true) ?? throwError("Failed to derive unique labels");
170
175
  }
171
176
 
172
177
  // --- Pure helpers ---
@@ -176,19 +181,57 @@ type EnrichedRecord = {
176
181
  fullTrace: FullTraceEntry[];
177
182
  };
178
183
 
179
- function extractSpecAndTrace(entry: Entry): {
184
+ function extractEntryParts(entry: Entry): {
180
185
  spec: PObjectSpec;
181
186
  extraTrace: ExtendedTraceEntry[] | undefined;
182
- linkerPath: { spec: PObjectSpec }[] | undefined;
187
+ linkerPath: LinkerStep[] | undefined;
188
+ qualifications: MatchQualifications | undefined;
183
189
  } {
184
190
  const isEnriched = "spec" in entry && typeof entry.spec === "object";
191
+ if (!isEnriched) {
192
+ return {
193
+ spec: entry as PObjectSpec,
194
+ extraTrace: undefined,
195
+ linkerPath: undefined,
196
+ qualifications: undefined,
197
+ };
198
+ }
185
199
  return {
186
- spec: isEnriched ? entry.spec : (entry as PObjectSpec),
187
- extraTrace: isEnriched ? entry.extraTrace : undefined,
188
- linkerPath: isEnriched ? entry.linkerPath : undefined,
200
+ spec: entry.spec,
201
+ extraTrace: entry.extraTrace,
202
+ linkerPath: entry.linkerPath,
203
+ qualifications: entry.qualifications,
189
204
  };
190
205
  }
191
206
 
207
+ function formatQualification(q: AxisQualification): string {
208
+ const ctx = q.contextDomain ?? {};
209
+ const keys = Object.keys(ctx);
210
+ if (keys.length === 0) return q.axis.name;
211
+ const pairs = keys.map((k) => `${k}=${ctx[k]}`).join(", ");
212
+ return Object.prototype.hasOwnProperty.call(ctx, q.axis.name) ? pairs : `${q.axis.name} ${pairs}`;
213
+ }
214
+
215
+ function formatQualifications(qs: AxisQualification[]): string {
216
+ return qs.map(formatQualification).join("; ");
217
+ }
218
+
219
+ function computeStepLabel(
220
+ step: LinkerStep,
221
+ stepIndex: number,
222
+ formatters: DeriveLabelsFormatters | undefined,
223
+ ): string | undefined {
224
+ const base = (
225
+ readAnnotation(step.spec, Annotation.LinkLabel) ?? readAnnotation(step.spec, Annotation.Label)
226
+ )?.trim();
227
+ if (isNil(base) || base.length === 0) return undefined;
228
+ if (step.qualifications === undefined || step.qualifications.length === 0) return base;
229
+ const qualText = isFunction(formatters?.linkerStepQualification)
230
+ ? formatters.linkerStepQualification(step.qualifications, stepIndex, step.spec)
231
+ : `[${formatQualifications(step.qualifications)}]`;
232
+ return isNil(qualText) ? base : `${base} ${qualText}`;
233
+ }
234
+
192
235
  function buildFullTrace(trace: ExtendedTraceEntry[]): FullTraceEntry[] {
193
236
  const result: FullTraceEntry[] = [];
194
237
  const occurrences = new Map<string, number>();
@@ -208,22 +251,65 @@ function buildFullTrace(trace: ExtendedTraceEntry[]): FullTraceEntry[] {
208
251
  return result;
209
252
  }
210
253
 
211
- function enrichRecord(value: Entry, options: DeriveLabelsOptions): EnrichedRecord {
212
- const { spec, extraTrace } = extractSpecAndTrace(value);
254
+ function enrichRecord(value: Entry, index: number, options: DeriveLabelsOptions): EnrichedRecord {
255
+ const { spec, extraTrace, linkerPath, qualifications } = extractEntryParts(value);
256
+ const formatters = options.formatters;
213
257
 
214
- const label = readAnnotation(spec, Annotation.Label);
258
+ const rawLabel = readAnnotation(spec, Annotation.Label);
215
259
  const traceStr = readAnnotation(spec, Annotation.Trace);
216
260
  const baseTrace = traceStr
217
261
  ? (parseJson(traceStr as StringifiedJson<ExtendedTraceEntry[]>) ?? [])
218
262
  : [];
219
263
  const prefixExtra = extraTrace?.filter((e) => e.position === "prefix") ?? [];
220
264
  const suffixExtra = extraTrace?.filter((e) => e.position !== "prefix") ?? [];
221
- const trace = [...prefixExtra, ...baseTrace, ...suffixExtra];
265
+ const trace: ExtendedTraceEntry[] = [...prefixExtra, ...baseTrace, ...suffixExtra];
266
+
267
+ if (!isNil(rawLabel)) {
268
+ const label = isFunction(formatters?.native)
269
+ ? formatters.native(rawLabel, spec, index)
270
+ : rawLabel;
271
+ if (!isNil(label)) {
272
+ const labelEntry = { label, type: LABEL_TYPE, importance: -2 };
273
+ if (options.addLabelAsSuffix === true) trace.push(labelEntry);
274
+ else trace.splice(0, 0, labelEntry);
275
+ }
276
+ }
222
277
 
223
- if (label !== undefined) {
224
- const labelEntry = { label, type: LABEL_TYPE, importance: -2 };
225
- if (options.addLabelAsSuffix) trace.push(labelEntry);
226
- else trace.splice(0, 0, labelEntry);
278
+ if (linkerPath !== undefined && linkerPath.length > 0) {
279
+ const stepLabels = linkerPath
280
+ .map((step, i) => computeStepLabel(step, i, formatters))
281
+ .filter((s): s is string => !isNil(s));
282
+ if (stepLabels.length > 0) {
283
+ const linkerText = isFunction(formatters?.linker)
284
+ ? formatters.linker(stepLabels, spec, index)
285
+ : `via ${stepLabels.join(" > ")}`;
286
+ if (!isNil(linkerText)) {
287
+ trace.push({ type: LINKER_TYPE, label: linkerText, importance: -10 });
288
+ }
289
+ }
290
+ }
291
+
292
+ if (qualifications !== undefined) {
293
+ for (const [anchorId, qs] of Object.entries(qualifications.forQueries)) {
294
+ if (qs.length === 0) continue;
295
+ const anchorText = isFunction(formatters?.anchorQualification)
296
+ ? formatters.anchorQualification(anchorId as PObjectId, qs, spec, index)
297
+ : `[${anchorId}: ${formatQualifications(qs)}]`;
298
+ if (isNil(anchorText)) continue;
299
+ trace.push({
300
+ type: `${ANCHOR_QUAL_TYPE_PREFIX}${anchorId}`,
301
+ label: anchorText,
302
+ importance: -11,
303
+ });
304
+ }
305
+ if (qualifications.forHit.length > 0) {
306
+ const hitText = isFunction(formatters?.hitQualification)
307
+ ? formatters.hitQualification(qualifications.forHit, spec, index)
308
+ : `[${formatQualifications(qualifications.forHit)}]`;
309
+ if (!isNil(hitText)) {
310
+ trace.push({ type: HIT_QUAL_TYPE, label: hitText, importance: -12 });
311
+ }
312
+ }
227
313
  }
228
314
 
229
315
  return { fullTrace: buildFullTrace(trace) };
@@ -283,20 +369,40 @@ function buildLabels(
283
369
  const result: string[] = [];
284
370
 
285
371
  for (const r of records) {
286
- const parts: string[] = [];
372
+ const traceParts: string[] = [];
373
+ const anchorParts: string[] = [];
374
+ let linkerLabel: string | undefined;
375
+ let hitLabel: string | undefined;
376
+
287
377
  for (const ft of r.fullTrace) {
288
- if (includedTypes.has(ft.fullType) || forceTraceElements?.has(ft.type)) {
289
- parts.push(ft.label);
290
- }
378
+ if (!(includedTypes.has(ft.fullType) || forceTraceElements?.has(ft.type))) continue;
379
+ if (ft.type === LINKER_TYPE) linkerLabel = ft.label;
380
+ else if (ft.type === HIT_QUAL_TYPE) hitLabel = ft.label;
381
+ else if (isAnchorQualType(ft.type)) anchorParts.push(ft.label);
382
+ else traceParts.push(ft.label);
291
383
  }
292
384
 
293
- if (parts.length === 0) {
385
+ const isEmpty =
386
+ traceParts.length === 0 &&
387
+ anchorParts.length === 0 &&
388
+ linkerLabel === undefined &&
389
+ hitLabel === undefined;
390
+
391
+ if (isEmpty) {
294
392
  if (!force) return undefined;
295
393
  result.push("Unlabeled");
296
394
  continue;
297
395
  }
298
396
 
299
- result.push(parts.join(separator));
397
+ let label = traceParts.join(separator);
398
+ const append = (part: string) => {
399
+ label = label.length === 0 ? part : `${label} ${part}`;
400
+ };
401
+ if (linkerLabel !== undefined) append(linkerLabel);
402
+ for (const a of anchorParts) append(a);
403
+ if (hitLabel !== undefined) append(hitLabel);
404
+
405
+ result.push(label);
300
406
  }
301
407
 
302
408
  return result;
@@ -312,7 +418,7 @@ function minimizeTypeSet(
312
418
  records: EnrichedRecord[],
313
419
  stats: TypeStats,
314
420
  forceTraceElements: Set<string> | undefined,
315
- options: DeriveLabelsOptions,
421
+ forcedSet: Set<string>,
316
422
  separator: string,
317
423
  ): Set<string> {
318
424
  const initialResult = buildLabels(records, typeSet, forceTraceElements, separator, false);
@@ -322,11 +428,7 @@ function minimizeTypeSet(
322
428
  const result = new Set(typeSet);
323
429
 
324
430
  const removable = [...result]
325
- .filter(
326
- (t) =>
327
- !forceTraceElements?.has(t.split("@")[0]) &&
328
- !(options.includeNativeLabel && t === LABEL_TYPE_FULL),
329
- )
431
+ .filter((t) => !forceTraceElements?.has(t.split("@")[0]) && !forcedSet.has(t))
330
432
  .sort((a, b) => (stats.importances.get(a) ?? 0) - (stats.importances.get(b) ?? 0));
331
433
 
332
434
  for (const typeToRemove of removable) {
@@ -110,22 +110,6 @@ describe("deriveDistinctTooltips", () => {
110
110
  expect(tooltip).toContain("sample context: batch=B");
111
111
  });
112
112
 
113
- test("distinctiveQualifications produces Distinctive section", () => {
114
- const entries: TooltipEntry[] = [
115
- {
116
- spec: createSpec("col1", "Col 1"),
117
- distinctiveQualifications: {
118
- forQueries: { ["main" as PObjectId]: [axisQualification("sample", { batch: "A" })] },
119
- forHit: [axisQualification("gene", { source: "Y" })],
120
- },
121
- },
122
- ];
123
- const [tooltip] = deriveDistinctTooltips(entries);
124
- expect(tooltip).toContain("Distinctive (what separates this variant)");
125
- expect(tooltip).toContain("main: sample context: batch=A");
126
- expect(tooltip).toContain("hit: gene context: source=Y");
127
- });
128
-
129
113
  test("variantCount > 1 adds Variant N of M line in header", () => {
130
114
  const entries: TooltipEntry[] = [
131
115
  {
@@ -195,21 +179,16 @@ describe("deriveDistinctTooltips", () => {
195
179
  forQueries: { ["main" as PObjectId]: [axisQualification("sample", { batch: "B" })] },
196
180
  forHit: [axisQualification("sample", { batch: "B" })],
197
181
  },
198
- distinctiveQualifications: {
199
- forQueries: { ["main" as PObjectId]: [axisQualification("sample", { batch: "B" })] },
200
- forHit: [],
201
- },
202
182
  },
203
183
  ];
204
184
  const [tooltip] = deriveDistinctTooltips(entries);
205
185
  expect(tooltip).toBeDefined();
206
186
  const sections = tooltip!.split("\n\n");
207
- expect(sections.length).toBe(5);
187
+ expect(sections.length).toBe(4);
208
188
  expect(sections[0]).toContain("Variant: 2 of 2");
209
189
  expect(sections[1]).toContain("Origin path");
210
190
  expect(sections[2]).toContain("Anchors");
211
191
  expect(sections[3]).toContain("Hit column qualifications");
212
- expect(sections[4]).toContain("Distinctive");
213
192
  });
214
193
 
215
194
  test("parallel results — array aligns with input", () => {
@@ -11,10 +11,8 @@ import type { MatchQualifications, MatchVariant } from "../columns";
11
11
  export type TooltipEntry = {
12
12
  /** Main column spec — used for column-name fallback when no label. */
13
13
  spec: PColumnSpec;
14
- /** Full qualifications carried by this variant. */
14
+ /** Qualifications carried by this variant. */
15
15
  qualifications?: MatchQualifications;
16
- /** Minimal qualifications that separate this variant from its siblings. */
17
- distinctiveQualifications?: MatchQualifications;
18
16
  /** Linker steps traversed to reach the hit column. */
19
17
  linkerPath?: MatchVariant["path"];
20
18
  /** Position of this variant within the same physical column (1-based). */
@@ -43,9 +41,6 @@ function formatTooltip(entry: TooltipEntry): undefined | string {
43
41
  const hit = formatHit(entry.qualifications);
44
42
  if (hit !== undefined) sections.push(hit);
45
43
 
46
- const distinctive = formatDistinctive(entry.distinctiveQualifications);
47
- if (distinctive !== undefined) sections.push(distinctive);
48
-
49
44
  if (sections.length <= 1) return undefined;
50
45
  return sections.join("\n\n");
51
46
  }
@@ -104,18 +99,6 @@ function formatHit(q: undefined | MatchQualifications): undefined | string {
104
99
  return ["Hit column qualifications", `${BULLET_1}${rendered}`].join("\n");
105
100
  }
106
101
 
107
- function formatDistinctive(q: undefined | MatchQualifications): undefined | string {
108
- if (isNil(q)) return undefined;
109
- const bullets: string[] = [];
110
- for (const id of Object.keys(q.forQueries)) {
111
- for (const item of q.forQueries[id as PObjectId])
112
- bullets.push(`${BULLET_1}${id}: ${formatQualification(item)}`);
113
- }
114
- for (const item of q.forHit) bullets.push(`${BULLET_1}hit: ${formatQualification(item)}`);
115
- if (bullets.length === 0) return undefined;
116
- return ["Distinctive (what separates this variant)", ...bullets].join("\n");
117
- }
118
-
119
102
  function formatAxisQualifications(qs: AxisQualification[]): undefined | string {
120
103
  if (qs.length === 0) return undefined;
121
104
  return qs.map(formatQualification).join("; ");