@platforma-sdk/model 1.65.9 → 1.66.2

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 (121) hide show
  1. package/dist/block_model.cjs +8 -11
  2. package/dist/block_model.cjs.map +1 -1
  3. package/dist/block_model.d.ts.map +1 -1
  4. package/dist/block_model.js +8 -10
  5. package/dist/block_model.js.map +1 -1
  6. package/dist/columns/column_collection_builder.cjs +61 -74
  7. package/dist/columns/column_collection_builder.cjs.map +1 -1
  8. package/dist/columns/column_collection_builder.d.ts +16 -22
  9. package/dist/columns/column_collection_builder.d.ts.map +1 -1
  10. package/dist/columns/column_collection_builder.js +62 -75
  11. package/dist/columns/column_collection_builder.js.map +1 -1
  12. package/dist/columns/column_selector.cjs.map +1 -1
  13. package/dist/columns/column_selector.d.ts +1 -1
  14. package/dist/columns/column_selector.js.map +1 -1
  15. package/dist/columns/column_snapshot.cjs.map +1 -1
  16. package/dist/columns/column_snapshot.d.ts +4 -4
  17. package/dist/columns/column_snapshot.d.ts.map +1 -1
  18. package/dist/columns/column_snapshot.js.map +1 -1
  19. package/dist/columns/ctx_column_sources.cjs.map +1 -1
  20. package/dist/columns/ctx_column_sources.d.ts +1 -1
  21. package/dist/columns/ctx_column_sources.d.ts.map +1 -1
  22. package/dist/columns/ctx_column_sources.js.map +1 -1
  23. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.cjs +2 -2
  24. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.cjs.map +1 -1
  25. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.js +2 -2
  26. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.js.map +1 -1
  27. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.cjs +17 -18
  28. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.cjs.map +1 -1
  29. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.js +17 -18
  30. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.js.map +1 -1
  31. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs +99 -91
  32. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
  33. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts +16 -16
  34. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts.map +1 -1
  35. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js +102 -94
  36. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
  37. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs +32 -23
  38. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs.map +1 -1
  39. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts +5 -5
  40. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts.map +1 -1
  41. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js +33 -24
  42. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js.map +1 -1
  43. package/dist/components/PlDataTable/createPlDataTable/index.cjs.map +1 -1
  44. package/dist/components/PlDataTable/createPlDataTable/index.d.ts +2 -3
  45. package/dist/components/PlDataTable/createPlDataTable/index.d.ts.map +1 -1
  46. package/dist/components/PlDataTable/createPlDataTable/index.js.map +1 -1
  47. package/dist/components/PlDataTable/createPlDataTable/utils.cjs +133 -16
  48. package/dist/components/PlDataTable/createPlDataTable/utils.cjs.map +1 -1
  49. package/dist/components/PlDataTable/createPlDataTable/utils.d.ts +8 -6
  50. package/dist/components/PlDataTable/createPlDataTable/utils.d.ts.map +1 -1
  51. package/dist/components/PlDataTable/createPlDataTable/utils.js +130 -17
  52. package/dist/components/PlDataTable/createPlDataTable/utils.js.map +1 -1
  53. package/dist/components/PlDataTable/labels.cjs +1 -2
  54. package/dist/components/PlDataTable/labels.cjs.map +1 -1
  55. package/dist/components/PlDataTable/labels.js +1 -2
  56. package/dist/components/PlDataTable/labels.js.map +1 -1
  57. package/dist/filters/distill.cjs +73 -30
  58. package/dist/filters/distill.cjs.map +1 -1
  59. package/dist/filters/distill.d.ts.map +1 -1
  60. package/dist/filters/distill.js +73 -30
  61. package/dist/filters/distill.js.map +1 -1
  62. package/dist/index.cjs +19 -15
  63. package/dist/index.d.ts +4 -2
  64. package/dist/index.js +6 -4
  65. package/dist/labels/derive_distinct_tooltips.cjs +85 -0
  66. package/dist/labels/derive_distinct_tooltips.cjs.map +1 -0
  67. package/dist/labels/derive_distinct_tooltips.d.ts +17 -0
  68. package/dist/labels/derive_distinct_tooltips.d.ts.map +1 -0
  69. package/dist/labels/derive_distinct_tooltips.js +84 -0
  70. package/dist/labels/derive_distinct_tooltips.js.map +1 -0
  71. package/dist/labels/index.cjs +1 -0
  72. package/dist/labels/index.d.ts +2 -1
  73. package/dist/labels/index.js +1 -0
  74. package/dist/package.cjs +1 -1
  75. package/dist/package.js +1 -1
  76. package/dist/render/api.cjs +8 -13
  77. package/dist/render/api.cjs.map +1 -1
  78. package/dist/render/api.d.ts +8 -11
  79. package/dist/render/api.d.ts.map +1 -1
  80. package/dist/render/api.js +8 -13
  81. package/dist/render/api.js.map +1 -1
  82. package/dist/services/get_services.cjs +19 -0
  83. package/dist/services/get_services.cjs.map +1 -0
  84. package/dist/services/get_services.d.ts +7 -0
  85. package/dist/services/get_services.d.ts.map +1 -0
  86. package/dist/services/get_services.js +19 -0
  87. package/dist/services/get_services.js.map +1 -0
  88. package/dist/services/index.cjs +1 -0
  89. package/dist/services/index.d.ts +2 -1
  90. package/dist/services/index.js +1 -0
  91. package/dist/services/service_bridge.cjs +4 -4
  92. package/dist/services/service_bridge.cjs.map +1 -1
  93. package/dist/services/service_bridge.d.ts +4 -4
  94. package/dist/services/service_bridge.d.ts.map +1 -1
  95. package/dist/services/service_bridge.js +4 -4
  96. package/dist/services/service_bridge.js.map +1 -1
  97. package/package.json +6 -6
  98. package/src/block_model.ts +8 -11
  99. package/src/columns/column_collection_builder.test.ts +75 -30
  100. package/src/columns/column_collection_builder.ts +96 -133
  101. package/src/columns/column_selector.ts +1 -1
  102. package/src/columns/column_snapshot.ts +7 -4
  103. package/src/columns/ctx_column_sources.ts +1 -3
  104. package/src/components/PFrameForGraphs.test.ts +4 -4
  105. package/src/components/PlDataTable/createPlDataTable/createPTableDefV2.ts +2 -2
  106. package/src/components/PlDataTable/createPlDataTable/createPTableDefV3.ts +44 -21
  107. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +202 -218
  108. package/src/components/PlDataTable/createPlDataTable/discoverColumns.ts +69 -56
  109. package/src/components/PlDataTable/createPlDataTable/index.ts +6 -7
  110. package/src/components/PlDataTable/createPlDataTable/utils.test.ts +97 -1
  111. package/src/components/PlDataTable/createPlDataTable/utils.ts +190 -35
  112. package/src/components/PlDataTable/labels.ts +3 -7
  113. package/src/filters/distill.test.ts +91 -0
  114. package/src/filters/distill.ts +102 -46
  115. package/src/labels/derive_distinct_tooltips.test.ts +233 -0
  116. package/src/labels/derive_distinct_tooltips.ts +130 -0
  117. package/src/labels/index.ts +1 -0
  118. package/src/render/api.ts +15 -50
  119. package/src/services/get_services.ts +28 -0
  120. package/src/services/index.ts +1 -0
  121. package/src/services/service_bridge.ts +5 -5
@@ -188,4 +188,95 @@ describe("distillFilterSpec", () => {
188
188
  const filter: F = { type: "and", filters: [a, b] };
189
189
  expect(distillFilterSpec(filter)).toBeNull();
190
190
  });
191
+
192
+ // --- per-type required-field checks ---
193
+
194
+ it("keeps patternFuzzyContainSubsequence without optional fields", () => {
195
+ const filter: F = { type: "patternFuzzyContainSubsequence", column: "c1", value: "abc" };
196
+ expect(distillFilterSpec(filter)).toEqual(filter);
197
+ });
198
+
199
+ it("keeps patternFuzzyContainSubsequence when optional fields undefined", () => {
200
+ const filter: F = {
201
+ type: "patternFuzzyContainSubsequence",
202
+ column: "c1",
203
+ value: "abc",
204
+ maxEdits: undefined,
205
+ substitutionsOnly: undefined,
206
+ wildcard: undefined,
207
+ };
208
+ expect(distillFilterSpec(filter)).toEqual(filter);
209
+ });
210
+
211
+ it("preserves optional fields on patternFuzzyContainSubsequence when filled", () => {
212
+ const filter: F = {
213
+ type: "patternFuzzyContainSubsequence",
214
+ column: "c1",
215
+ value: "abc",
216
+ maxEdits: 2,
217
+ substitutionsOnly: true,
218
+ wildcard: "*",
219
+ };
220
+ expect(distillFilterSpec(filter)).toEqual(filter);
221
+ });
222
+
223
+ it("keeps lessThanColumn without optional minDiff", () => {
224
+ const filter: F = { type: "lessThanColumn", column: "c1", rhs: "c2" };
225
+ expect(distillFilterSpec(filter)).toEqual(filter);
226
+ });
227
+
228
+ it("returns null for lessThanColumn with missing required rhs", () => {
229
+ const filter: F = {
230
+ type: "lessThanColumn",
231
+ column: "c1",
232
+ rhs: undefined as unknown as string,
233
+ };
234
+ expect(distillFilterSpec(filter)).toBeNull();
235
+ });
236
+
237
+ it("returns null for ifNa with missing required replacement", () => {
238
+ const filter: F = {
239
+ type: "ifNa",
240
+ column: "c1",
241
+ replacement: undefined as unknown as string,
242
+ };
243
+ expect(distillFilterSpec(filter)).toBeNull();
244
+ });
245
+
246
+ // --- isFilledValue edge cases ---
247
+
248
+ it("returns null for empty-string value after trim", () => {
249
+ const filter: F = { type: "patternEquals", column: "c1", value: " " };
250
+ expect(distillFilterSpec(filter)).toBeNull();
251
+ });
252
+
253
+ it("returns null for inSet with empty array", () => {
254
+ const filter: F = { type: "inSet", column: "c1", value: [] };
255
+ expect(distillFilterSpec(filter)).toBeNull();
256
+ });
257
+
258
+ it("returns null for inSet with array containing empty string", () => {
259
+ const filter: F = { type: "inSet", column: "c1", value: ["a", " "] };
260
+ expect(distillFilterSpec(filter)).toBeNull();
261
+ });
262
+
263
+ it("keeps inSet with non-empty string items", () => {
264
+ const filter: F = { type: "inSet", column: "c1", value: ["a", "b"] };
265
+ expect(distillFilterSpec(filter)).toEqual(filter);
266
+ });
267
+
268
+ it("keeps equal with x=0 (falsy but filled)", () => {
269
+ const filter: F = { type: "equal", column: "c1", x: 0 };
270
+ expect(distillFilterSpec(filter)).toEqual(filter);
271
+ });
272
+
273
+ it("keeps isNA (single required column)", () => {
274
+ const filter: F = { type: "isNA", column: "c1" };
275
+ expect(distillFilterSpec(filter)).toEqual(filter);
276
+ });
277
+
278
+ it("returns null for isNA with missing column", () => {
279
+ const filter: F = { type: "isNA", column: undefined as unknown as string };
280
+ expect(distillFilterSpec(filter)).toBeNull();
281
+ });
191
282
  });
@@ -1,4 +1,4 @@
1
- import { DistributiveKeys, UnionToTuples } from "@milaboratories/helpers";
1
+ import { DistributiveKeys, isNil, UnionToTuples } from "@milaboratories/helpers";
2
2
  import {
3
3
  RootFilterSpec,
4
4
  type FilterSpec,
@@ -8,51 +8,6 @@ import { traverseFilterSpec } from "./traverse";
8
8
  import { InferFilterSpecLeaf } from "@milaboratories/pl-model-common";
9
9
  import { isEmpty } from "es-toolkit/compat";
10
10
 
11
- /** All possible field names that can appear in any FilterSpecLeaf variant. */
12
- type FilterSpecLeafKey = DistributiveKeys<FilterSpecLeaf<string>>;
13
-
14
- /** Compile-time check: every key in the tuple is a valid leaf key (via satisfies). */
15
- const KNOWN_LEAF_KEYS_TUPLE: UnionToTuples<FilterSpecLeafKey> = [
16
- "n",
17
- "x",
18
- "rhs",
19
- "type",
20
- "value",
21
- "column",
22
- "minDiff",
23
- "maxEdits",
24
- "wildcard",
25
- "replacement",
26
- "substitutionsOnly",
27
- ];
28
- const KNOWN_LEAF_KEYS: Set<FilterSpecLeafKey> = new Set(KNOWN_LEAF_KEYS_TUPLE);
29
-
30
- /** Returns true if the leaf is filled — type is defined and no required fields are undefined. */
31
- function isFilledLeaf<T>(node: FilterSpecLeaf<T>): boolean {
32
- if (node.type == null) return false;
33
- return !Object.values(node).some((value) => {
34
- switch (typeof value) {
35
- case "number":
36
- case "boolean":
37
- return false;
38
- case "string":
39
- return value.trim() === "";
40
- default: // undefined, null, empty objects/arrays
41
- return isEmpty(value);
42
- }
43
- });
44
- }
45
-
46
- function distillLeaf<T>(node: FilterSpecLeaf<T>): FilterSpecLeaf<T> {
47
- const result: Record<string, unknown> = {};
48
- for (const [key, value] of Object.entries(node)) {
49
- if (KNOWN_LEAF_KEYS.has(key as FilterSpecLeafKey)) {
50
- result[key] = value;
51
- }
52
- }
53
- return result as FilterSpecLeaf<T>;
54
- }
55
-
56
11
  /**
57
12
  * Strips non-FilterSpec metadata (whitelist approach) and removes
58
13
  * unfilled leaves (type is undefined or any required field is undefined).
@@ -80,3 +35,104 @@ export function distillFilterSpec<
80
35
  not: (result) => (result === null ? null : ({ type: "not", filter: result } as R)),
81
36
  });
82
37
  }
38
+
39
+ function distillLeaf<T>(node: FilterSpecLeaf<T>): FilterSpecLeaf<T> {
40
+ const result: Record<string, unknown> = {};
41
+ for (const [key, value] of Object.entries(node)) {
42
+ if (KNOWN_LEAF_KEYS.has(key as FilterSpecLeafKey)) {
43
+ result[key] = value;
44
+ }
45
+ }
46
+ return result as FilterSpecLeaf<T>;
47
+ }
48
+
49
+ /** Returns true if the leaf is filled — type is defined and every required field per-type is filled. */
50
+ function isFilledLeaf<T>(node: FilterSpecLeaf<T>): boolean {
51
+ if (isNil(node.type)) return false;
52
+ const required = REQUIRED_KEYS_BY_TYPE[node.type];
53
+ const record = node as Record<string, unknown>;
54
+ return required.every((key) => isFilledValue(record[key]));
55
+ }
56
+
57
+ /**
58
+ * Returns true if the value is considered "filled":
59
+ * - primitives (number, boolean): always true
60
+ * - string: non-empty after trim
61
+ * - array: non-empty AND every item is filled
62
+ * - plain object: non-empty AND every field value is filled
63
+ * - null/undefined: false
64
+ */
65
+ function isFilledValue(value: unknown): boolean {
66
+ if (isNil(value)) return false;
67
+ switch (typeof value) {
68
+ case "number":
69
+ case "boolean":
70
+ return true;
71
+ case "string":
72
+ return value.trim() !== "";
73
+ default:
74
+ if (isEmpty(value)) return false;
75
+ if (Array.isArray(value)) return value.every(isFilledValue);
76
+ return Object.values(value as Record<string, unknown>).every(isFilledValue);
77
+ }
78
+ }
79
+
80
+ /** All possible field names that can appear in any FilterSpecLeaf variant. */
81
+ type FilterSpecLeafKey = DistributiveKeys<FilterSpecLeaf<string>>;
82
+
83
+ /** Leaf type discriminators (excludes the placeholder `undefined` variant). */
84
+ type FilterSpecLeafType = Exclude<FilterSpecLeaf<unknown>, { type: undefined }>["type"];
85
+
86
+ type LeafOfType<T extends FilterSpecLeafType> = Extract<FilterSpecLeaf<unknown>, { type: T }>;
87
+
88
+ type RequiredKeys<O> = { [K in keyof O]-?: {} extends Pick<O, K> ? never : K }[keyof O];
89
+
90
+ /** Required field keys of a given leaf variant (excluding the `type` discriminator). */
91
+ type RequiredLeafKeys<T extends FilterSpecLeafType> = Exclude<RequiredKeys<LeafOfType<T>>, "type">;
92
+
93
+ /** Exact per-type shape — adding a key not required by that variant becomes a type error. */
94
+ type RequiredKeysByType = { readonly [T in FilterSpecLeafType]: readonly RequiredLeafKeys<T>[] };
95
+
96
+ /** Compile-time check: every key in the tuple is a valid leaf key (via satisfies). */
97
+ const KNOWN_LEAF_KEYS_TUPLE: UnionToTuples<FilterSpecLeafKey> = [
98
+ "n",
99
+ "x",
100
+ "rhs",
101
+ "type",
102
+ "value",
103
+ "column",
104
+ "minDiff",
105
+ "maxEdits",
106
+ "wildcard",
107
+ "replacement",
108
+ "substitutionsOnly",
109
+ ];
110
+ const KNOWN_LEAF_KEYS: Set<FilterSpecLeafKey> = new Set(KNOWN_LEAF_KEYS_TUPLE);
111
+
112
+ /** Required fields per leaf type. Optional fields (e.g. minDiff, maxEdits) excluded. */
113
+ const REQUIRED_KEYS_BY_TYPE = {
114
+ isNA: ["column"],
115
+ isNotNA: ["column"],
116
+ ifNa: ["column", "replacement"],
117
+ patternEquals: ["column", "value"],
118
+ patternNotEquals: ["column", "value"],
119
+ patternContainSubsequence: ["column", "value"],
120
+ patternNotContainSubsequence: ["column", "value"],
121
+ patternMatchesRegularExpression: ["column", "value"],
122
+ patternFuzzyContainSubsequence: ["column", "value"],
123
+ inSet: ["column", "value"],
124
+ notInSet: ["column", "value"],
125
+ topN: ["column", "n"],
126
+ bottomN: ["column", "n"],
127
+ equal: ["column", "x"],
128
+ notEqual: ["column", "x"],
129
+ lessThan: ["column", "x"],
130
+ greaterThan: ["column", "x"],
131
+ lessThanOrEqual: ["column", "x"],
132
+ greaterThanOrEqual: ["column", "x"],
133
+ equalToColumn: ["column", "rhs"],
134
+ lessThanColumn: ["column", "rhs"],
135
+ greaterThanColumn: ["column", "rhs"],
136
+ lessThanColumnOrEqual: ["column", "rhs"],
137
+ greaterThanColumnOrEqual: ["column", "rhs"],
138
+ } as const satisfies RequiredKeysByType;
@@ -0,0 +1,233 @@
1
+ import {
2
+ Annotation,
3
+ type AxisQualification,
4
+ type PColumnSpec,
5
+ type PObjectId,
6
+ } from "@milaboratories/pl-model-common";
7
+ import { describe, expect, test } from "vitest";
8
+ import { deriveDistinctTooltips, type TooltipEntry } from "./derive_distinct_tooltips";
9
+ import type { ColumnSnapshot, MatchVariant } from "../columns";
10
+
11
+ function createSpec(name: string, label?: string): PColumnSpec {
12
+ return {
13
+ kind: "PColumn",
14
+ name,
15
+ valueType: "Int",
16
+ axesSpec: [],
17
+ annotations: label !== undefined ? { [Annotation.Label]: label } : {},
18
+ } as PColumnSpec;
19
+ }
20
+
21
+ function axisQualification(
22
+ axisName: string,
23
+ contextDomain: Record<string, string>,
24
+ ): AxisQualification {
25
+ return { axis: { name: axisName }, contextDomain };
26
+ }
27
+
28
+ function linkerSnapshot(name: string, label?: string): ColumnSnapshot<PObjectId> {
29
+ return {
30
+ id: `linker-${name}` as PObjectId,
31
+ spec: createSpec(name, label),
32
+ dataStatus: "ready",
33
+ data: undefined,
34
+ };
35
+ }
36
+
37
+ function pathStep(
38
+ linkerName: string,
39
+ qualifications: AxisQualification[],
40
+ label?: string,
41
+ ): MatchVariant["path"][number] {
42
+ return { linker: linkerSnapshot(linkerName, label), qualifications };
43
+ }
44
+
45
+ describe("deriveDistinctTooltips", () => {
46
+ test("empty entry (no qualifications, no linker path) → undefined", () => {
47
+ const entries: TooltipEntry[] = [{ spec: createSpec("col1") }];
48
+ expect(deriveDistinctTooltips(entries)).toEqual([undefined]);
49
+ });
50
+
51
+ test("only header info but no sections → undefined (single section filtered)", () => {
52
+ const entries: TooltipEntry[] = [
53
+ {
54
+ spec: createSpec("col1", "Column 1"),
55
+ variantIndex: 1,
56
+ variantCount: 1,
57
+ },
58
+ ];
59
+ expect(deriveDistinctTooltips(entries)).toEqual([undefined]);
60
+ });
61
+
62
+ test("linker path produces Origin path section", () => {
63
+ const entries: TooltipEntry[] = [
64
+ {
65
+ spec: createSpec("hit_col", "Hit Col"),
66
+ linkerPath: [
67
+ pathStep("linker_a", [axisQualification("sample", { batch: "A" })], "Linker A"),
68
+ ],
69
+ },
70
+ ];
71
+ const [tooltip] = deriveDistinctTooltips(entries);
72
+ expect(tooltip).toBeDefined();
73
+ expect(tooltip).toContain("Origin path");
74
+ expect(tooltip).toContain("linker 1: Linker A");
75
+ expect(tooltip).toContain("qualifies: sample context: batch=A");
76
+ expect(tooltip).toContain("hit column: Hit Col");
77
+ });
78
+
79
+ test("qualifications.forQueries produces Anchors section", () => {
80
+ const entries: TooltipEntry[] = [
81
+ {
82
+ spec: createSpec("col1", "Col 1"),
83
+ qualifications: {
84
+ forQueries: {
85
+ ["main" as PObjectId]: [axisQualification("sample", { batch: "A" })],
86
+ ["other" as PObjectId]: [axisQualification("gene", { source: "X" })],
87
+ },
88
+ forHit: [],
89
+ },
90
+ },
91
+ ];
92
+ const [tooltip] = deriveDistinctTooltips(entries);
93
+ expect(tooltip).toContain("Anchors (bound via this variant)");
94
+ expect(tooltip).toContain("main sample context: batch=A");
95
+ expect(tooltip).toContain("other gene context: source=X");
96
+ });
97
+
98
+ test("qualifications.forHit produces Hit section", () => {
99
+ const entries: TooltipEntry[] = [
100
+ {
101
+ spec: createSpec("col1", "Col 1"),
102
+ qualifications: {
103
+ forQueries: {},
104
+ forHit: [axisQualification("sample", { batch: "B" })],
105
+ },
106
+ },
107
+ ];
108
+ const [tooltip] = deriveDistinctTooltips(entries);
109
+ expect(tooltip).toContain("Hit column qualifications");
110
+ expect(tooltip).toContain("sample context: batch=B");
111
+ });
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
+ test("variantCount > 1 adds Variant N of M line in header", () => {
130
+ const entries: TooltipEntry[] = [
131
+ {
132
+ spec: createSpec("col1", "Col 1"),
133
+ variantIndex: 1,
134
+ variantCount: 2,
135
+ qualifications: {
136
+ forQueries: { ["main" as PObjectId]: [axisQualification("sample", { batch: "A" })] },
137
+ forHit: [],
138
+ },
139
+ },
140
+ ];
141
+ const [tooltip] = deriveDistinctTooltips(entries);
142
+ expect(tooltip).toContain("Variant: 1 of 2");
143
+ });
144
+
145
+ test("axis qualification with empty contextDomain shows only axis name", () => {
146
+ const entries: TooltipEntry[] = [
147
+ {
148
+ spec: createSpec("col1", "Col 1"),
149
+ qualifications: {
150
+ forQueries: { ["main" as PObjectId]: [axisQualification("sample", {})] },
151
+ forHit: [],
152
+ },
153
+ },
154
+ ];
155
+ const [tooltip] = deriveDistinctTooltips(entries);
156
+ expect(tooltip).toContain("main sample");
157
+ expect(tooltip).not.toContain("context:");
158
+ });
159
+
160
+ test("empty forQueries object → no Anchors section", () => {
161
+ const entries: TooltipEntry[] = [
162
+ {
163
+ spec: createSpec("col1", "Col 1"),
164
+ qualifications: { forQueries: {}, forHit: [] },
165
+ linkerPath: [pathStep("linker_a", [], "Linker A")],
166
+ },
167
+ ];
168
+ const [tooltip] = deriveDistinctTooltips(entries);
169
+ expect(tooltip).not.toContain("Anchors");
170
+ });
171
+
172
+ test("multi-step linker path numbers sequentially", () => {
173
+ const entries: TooltipEntry[] = [
174
+ {
175
+ spec: createSpec("hit_col", "Hit Col"),
176
+ linkerPath: [
177
+ pathStep("linker_a", [], "Linker A"),
178
+ pathStep("linker_b", [axisQualification("sample", { batch: "B" })], "Linker B"),
179
+ ],
180
+ },
181
+ ];
182
+ const [tooltip] = deriveDistinctTooltips(entries);
183
+ expect(tooltip).toContain("linker 1: Linker A");
184
+ expect(tooltip).toContain("linker 2: Linker B");
185
+ });
186
+
187
+ test("all sections compose with double-newline separators", () => {
188
+ const entries: TooltipEntry[] = [
189
+ {
190
+ spec: createSpec("hit_col", "Hit"),
191
+ variantIndex: 2,
192
+ variantCount: 2,
193
+ linkerPath: [pathStep("linker_a", [axisQualification("sample", { batch: "B" })], "LA")],
194
+ qualifications: {
195
+ forQueries: { ["main" as PObjectId]: [axisQualification("sample", { batch: "B" })] },
196
+ forHit: [axisQualification("sample", { batch: "B" })],
197
+ },
198
+ distinctiveQualifications: {
199
+ forQueries: { ["main" as PObjectId]: [axisQualification("sample", { batch: "B" })] },
200
+ forHit: [],
201
+ },
202
+ },
203
+ ];
204
+ const [tooltip] = deriveDistinctTooltips(entries);
205
+ expect(tooltip).toBeDefined();
206
+ const sections = tooltip!.split("\n\n");
207
+ expect(sections.length).toBe(5);
208
+ expect(sections[0]).toContain("Variant: 2 of 2");
209
+ expect(sections[1]).toContain("Origin path");
210
+ expect(sections[2]).toContain("Anchors");
211
+ expect(sections[3]).toContain("Hit column qualifications");
212
+ expect(sections[4]).toContain("Distinctive");
213
+ });
214
+
215
+ test("parallel results — array aligns with input", () => {
216
+ const entries: TooltipEntry[] = [
217
+ { spec: createSpec("a") },
218
+ {
219
+ spec: createSpec("b", "B"),
220
+ qualifications: {
221
+ forQueries: { ["main" as PObjectId]: [axisQualification("sample", { batch: "A" })] },
222
+ forHit: [],
223
+ },
224
+ },
225
+ { spec: createSpec("c") },
226
+ ];
227
+ const result = deriveDistinctTooltips(entries);
228
+ expect(result.length).toBe(3);
229
+ expect(result[0]).toBeUndefined();
230
+ expect(result[1]).toBeDefined();
231
+ expect(result[2]).toBeUndefined();
232
+ });
233
+ });
@@ -0,0 +1,130 @@
1
+ import {
2
+ Annotation,
3
+ PObjectId,
4
+ readAnnotation,
5
+ type AxisQualification,
6
+ type PColumnSpec,
7
+ } from "@milaboratories/pl-model-common";
8
+ import { isNil } from "es-toolkit";
9
+ import type { MatchQualifications, MatchVariant } from "../columns";
10
+
11
+ export type TooltipEntry = {
12
+ /** Main column spec — used for column-name fallback when no label. */
13
+ spec: PColumnSpec;
14
+ /** Full qualifications carried by this variant. */
15
+ qualifications?: MatchQualifications;
16
+ /** Minimal qualifications that separate this variant from its siblings. */
17
+ distinctiveQualifications?: MatchQualifications;
18
+ /** Linker steps traversed to reach the hit column. */
19
+ linkerPath?: MatchVariant["path"];
20
+ /** Position of this variant within the same physical column (1-based). */
21
+ variantIndex?: number;
22
+ /** Total variants for the same physical column. */
23
+ variantCount?: number;
24
+ };
25
+
26
+ /** Format tooltip strings for each entry. Returns `undefined` when nothing useful. */
27
+ export function deriveDistinctTooltips(entries: TooltipEntry[]): (undefined | string)[] {
28
+ return entries.map(formatTooltip);
29
+ }
30
+
31
+ function formatTooltip(entry: TooltipEntry): undefined | string {
32
+ const sections: string[] = [];
33
+
34
+ const header = formatHeader(entry);
35
+ if (header !== undefined) sections.push(header);
36
+
37
+ const origin = formatOriginPath(entry);
38
+ if (origin !== undefined) sections.push(origin);
39
+
40
+ const anchors = formatAnchors(entry.qualifications);
41
+ if (anchors !== undefined) sections.push(anchors);
42
+
43
+ const hit = formatHit(entry.qualifications);
44
+ if (hit !== undefined) sections.push(hit);
45
+
46
+ const distinctive = formatDistinctive(entry.distinctiveQualifications);
47
+ if (distinctive !== undefined) sections.push(distinctive);
48
+
49
+ if (sections.length <= 1) return undefined;
50
+ return sections.join("\n\n");
51
+ }
52
+
53
+ const BULLET_1 = " • ";
54
+ const SUB_BULLET = " ";
55
+
56
+ function formatHeader(entry: TooltipEntry): undefined | string {
57
+ const lines: string[] = [];
58
+ if (entry.variantCount !== undefined && entry.variantCount > 1) {
59
+ lines.push(`Variant: ${entry.variantIndex ?? "?"} of ${entry.variantCount}`);
60
+ }
61
+ return lines.join("\n");
62
+ }
63
+
64
+ function formatOriginPath(entry: TooltipEntry): undefined | string {
65
+ const path = entry.linkerPath ?? [];
66
+ if (path.length === 0) return undefined;
67
+
68
+ const lines = ["Origin path"];
69
+ path.forEach((step, i) => {
70
+ const label =
71
+ readAnnotation(step.linker.spec, Annotation.LinkLabel) ??
72
+ readAnnotation(step.linker.spec, Annotation.Label) ??
73
+ step.linker.spec.name;
74
+ lines.push(`${BULLET_1}linker ${i + 1}: ${label}`);
75
+ const qs = formatAxisQualifications(step.qualifications);
76
+ if (qs !== undefined) lines.push(`${SUB_BULLET}qualifies: ${qs}`);
77
+ });
78
+ const hitName = readAnnotation(entry.spec, Annotation.Label) ?? entry.spec.name;
79
+ lines.push(`${BULLET_1}hit column: ${hitName}`);
80
+ return lines.join("\n");
81
+ }
82
+
83
+ function formatAnchors(q: undefined | MatchQualifications): undefined | string {
84
+ if (isNil(q)) return undefined;
85
+ const ids = Object.keys(q.forQueries);
86
+ if (ids.length === 0) return undefined;
87
+
88
+ const lines = [];
89
+ for (const id of ids) {
90
+ const item = q.forQueries[id as PObjectId];
91
+ if (item.length === 0) continue;
92
+ const rendered = formatAxisQualifications(item);
93
+ lines.push(`${BULLET_1}${id}${rendered !== undefined ? ` ${rendered}` : ""}`);
94
+ }
95
+ return lines.length > 0
96
+ ? ["Anchors (bound via this variant)"].concat(lines).join("\n")
97
+ : undefined;
98
+ }
99
+
100
+ function formatHit(q: undefined | MatchQualifications): undefined | string {
101
+ if (isNil(q) || q.forHit.length === 0) return undefined;
102
+ const rendered = formatAxisQualifications(q.forHit);
103
+ if (rendered === undefined) return undefined;
104
+ return ["Hit column qualifications", `${BULLET_1}${rendered}`].join("\n");
105
+ }
106
+
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
+ function formatAxisQualifications(qs: AxisQualification[]): undefined | string {
120
+ if (qs.length === 0) return undefined;
121
+ return qs.map(formatQualification).join("; ");
122
+ }
123
+
124
+ function formatQualification(q: AxisQualification): string {
125
+ const axisName = typeof q.axis === "string" ? q.axis : (q.axis.name ?? JSON.stringify(q.axis));
126
+ const entries = Object.entries(q.contextDomain);
127
+ if (entries.length === 0) return axisName;
128
+ const kv = entries.map(([k, v]) => `${k}=${v}`).join(", ");
129
+ return `${axisName} context: ${kv}`;
130
+ }
@@ -1 +1,2 @@
1
1
  export * from "./derive_distinct_labels";
2
+ export * from "./derive_distinct_tooltips";