@platforma-open/milaboratories.immune-assay-data.ui 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta http-equiv="Content-Security-Policy" content="script-src 'self' blob: https: 'unsafe-eval';">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <script type="module" crossorigin src="./assets/index-hSG_lBZY.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-BsJvu2Wd.css">
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ </body>
13
+ </html>
@@ -0,0 +1,4 @@
1
+ import { ui } from '@platforma-sdk/eslint-config';
2
+
3
+ /** @type {import('eslint').Linter.Config[]} */
4
+ export default [...ui];
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta http-equiv="Content-Security-Policy" content="script-src 'self' blob: https: 'unsafe-eval';">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@platforma-open/milaboratories.immune-assay-data.ui",
3
+ "version": "1.0.1",
4
+ "type": "module",
5
+ "dependencies": {
6
+ "@platforma-sdk/ui-vue": "^1.31.16",
7
+ "@platforma-sdk/model": "^1.31.16",
8
+ "@milaboratories/graph-maker": "^1.1.101",
9
+ "vue": "^3.5.13",
10
+ "@biowasm/aioli": "~3.2.1",
11
+ "sass-embedded": "^1.77.8",
12
+ "xlsx": "../vendor/xlsx-0.20.3.tgz",
13
+ "@platforma-open/milaboratories.immune-assay-data.model": "1.0.1"
14
+ },
15
+ "devDependencies": {
16
+ "@vitejs/plugin-vue": "^5.2.1",
17
+ "typescript": "~5.5.4",
18
+ "vite": "^6.2.2",
19
+ "vitest": "^2.1.8",
20
+ "vue-tsc": "^2.2.8",
21
+ "@platforma-sdk/eslint-config": "^1.0.3"
22
+ },
23
+ "scripts": {
24
+ "dev": "vite",
25
+ "watch": "vue-tsc && vite build --watch",
26
+ "build": "vue-tsc -b && vite build",
27
+ "lint": "eslint .",
28
+ "preview": "vite preview",
29
+ "test": "vitest"
30
+ }
31
+ }
package/src/app.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { model } from '@platforma-open/milaboratories.immune-assay-data.model';
2
+ import { defineApp } from '@platforma-sdk/ui-vue';
3
+ import MainPage from './pages/MainPage.vue';
4
+
5
+ export const sdkPlugin = defineApp(model, () => {
6
+ return {
7
+ routes: {
8
+ '/': () => MainPage,
9
+ },
10
+ };
11
+ });
12
+
13
+ export const useApp = sdkPlugin.useApp;
@@ -0,0 +1,184 @@
1
+ import { getFileNameFromHandle, getRawPlatformaInstance, type LocalImportFileHandle } from '@platforma-sdk/model';
2
+
3
+ import { useApp } from './app';
4
+
5
+ import type { ImportColumnInfo } from '@platforma-open/milaboratories.immune-assay-data.model';
6
+ import * as XLSX from 'xlsx';
7
+
8
+ // Define a more specific type for raw Excel data
9
+ type TableRow = string[];
10
+ type TableData = TableRow[];
11
+
12
+ // Helper function to infer data type from a value
13
+ function inferValueType(value: unknown): 'Int' | 'Double' | 'String' {
14
+ if (value === null || value === undefined || value === '') {
15
+ return 'String'; // Default to String for empty values
16
+ }
17
+
18
+ const stringValue = String(value).trim();
19
+
20
+ // Try to parse as integer
21
+ const intValue = parseInt(stringValue, 10);
22
+ if (!isNaN(intValue) && String(intValue) === stringValue) {
23
+ return 'Int';
24
+ }
25
+
26
+ // Try to parse as double/float
27
+ const floatValue = parseFloat(stringValue);
28
+ if (!isNaN(floatValue) && isFinite(floatValue) && /^-?\d*\.?\d+([eE][+-]?\d+)?$/.test(stringValue)) {
29
+ return 'Double';
30
+ }
31
+
32
+ return 'String';
33
+ }
34
+
35
+ // Helper function to infer column type from array of values
36
+ function inferColumnType(values: unknown[]): 'Int' | 'Double' | 'String' {
37
+ const typeCounts = { Int: 0, Double: 0, String: 0 };
38
+ let nonEmptyCount = 0;
39
+
40
+ for (const value of values) {
41
+ if (value !== null && value !== undefined && String(value).trim() !== '') {
42
+ nonEmptyCount++;
43
+ const type = inferValueType(value);
44
+ typeCounts[type]++;
45
+ }
46
+ }
47
+
48
+ // If no non-empty values, default to String
49
+ if (nonEmptyCount === 0) {
50
+ return 'String';
51
+ }
52
+
53
+ // If any value is String, the whole column is String
54
+ if (typeCounts.String > 0) {
55
+ return 'String';
56
+ }
57
+
58
+ // If any value is Double, the whole column is Double
59
+ if (typeCounts.Double > 0) {
60
+ return 'Double';
61
+ }
62
+
63
+ // Otherwise, it's Int
64
+ return 'Int';
65
+ }
66
+
67
+ // Helper function to infer sequence type from array of values
68
+ function inferSequenceType(values: unknown[]): 'nucleotide' | 'aminoacid' | undefined {
69
+ const validSequences = values
70
+ .filter((v) => v !== null && v !== undefined && String(v).trim() !== '')
71
+ .map((v) => String(v).trim())
72
+ .filter((v) => v.length >= 3) // Only consider sequences of meaningful length
73
+ .slice(0, 1000); // Check only first 1000 sequences
74
+
75
+ if (validSequences.length === 0) return undefined;
76
+
77
+ // Count characters across all sequences
78
+ let nucleotideCharCount = 0;
79
+ let aminoAcidCharCount = 0;
80
+ let totalCharCount = 0;
81
+
82
+ // Basic nucleotide characters
83
+ const nucleotideChars = new Set(['A', 'T', 'G', 'C', 'N']);
84
+ // Standard 20 amino acids plus X and * (excluding nucleotide overlap)
85
+ const aminoAcidOnlyChars = new Set(['R', 'D', 'E', 'Q', 'H', 'I', 'L', 'K', 'M', 'F', 'P', 'S', 'W', 'Y', 'V', 'X', '*']);
86
+
87
+ for (const sequence of validSequences) {
88
+ const upperSeq = sequence.toUpperCase();
89
+ for (const char of upperSeq) {
90
+ if (char === '-') {
91
+ // Skip gap characters
92
+ continue;
93
+ }
94
+
95
+ if (nucleotideChars.has(char)) {
96
+ nucleotideCharCount++;
97
+ } else if (aminoAcidOnlyChars.has(char)) {
98
+ aminoAcidCharCount++;
99
+ }
100
+ totalCharCount++;
101
+ }
102
+ }
103
+
104
+ const totalValidCharCount = nucleotideCharCount + aminoAcidCharCount;
105
+
106
+ // Need at least some valid characters to make a decision
107
+ if (totalValidCharCount < 10 || totalValidCharCount / totalCharCount < 0.9) {
108
+ return undefined;
109
+ }
110
+
111
+ // Calculate percentages
112
+ const nucleotidePercentage = nucleotideCharCount / totalValidCharCount;
113
+
114
+ // If nucleotide characters dominate (>90%), it's nucleotide
115
+ if (nucleotidePercentage > 0.9) {
116
+ return 'nucleotide';
117
+ } else {
118
+ return 'aminoacid';
119
+ }
120
+ }
121
+
122
+ export async function importFile(file: LocalImportFileHandle) {
123
+ const app = useApp();
124
+
125
+ app.model.args.fileHandle = file;
126
+
127
+ // clear state
128
+ app.model.args.importColumns = undefined;
129
+ app.model.ui.fileImportError = undefined;
130
+
131
+ const fileName = getFileNameFromHandle(file);
132
+ const extension = fileName.split('.').pop();
133
+ app.model.args.fileExtension = extension;
134
+
135
+ if (extension === 'xlsx' || extension === 'xls') {
136
+ app.model.ui.fileImportError = 'XLS import is not yet available; use CSV instead';
137
+ return;
138
+ }
139
+
140
+ const data = await getRawPlatformaInstance().lsDriver.getLocalFileContent(file);
141
+ const wb = XLSX.read(data);
142
+
143
+ // @TODO: allow user to select worksheet
144
+ const worksheet = wb.Sheets[wb.SheetNames[0]];
145
+
146
+ const rawData = XLSX.utils.sheet_to_json(worksheet, {
147
+ header: 1,
148
+ raw: true,
149
+ blankrows: false,
150
+ }) as TableData;
151
+
152
+ const header = rawData[0];
153
+ if (!header) {
154
+ app.model.ui.fileImportError = 'File does not contain any data';
155
+ return;
156
+ }
157
+
158
+ if (new Set(header).size !== header.length) {
159
+ app.model.ui.fileImportError = 'Headers in the input file must be unique';
160
+ return;
161
+ }
162
+
163
+ const importColumns: ImportColumnInfo[] = [];
164
+
165
+ // Process each column to infer its type
166
+ for (let colIndex = 0; colIndex < header.length; colIndex++) {
167
+ const columnHeader = header[colIndex];
168
+ const columnValues = rawData.slice(1).map((row) => row[colIndex]);
169
+ const inferredType = inferColumnType(columnValues);
170
+ const sequenceType = inferredType === 'String' ? inferSequenceType(columnValues) : undefined;
171
+
172
+ importColumns.push({
173
+ header: columnHeader,
174
+ type: inferredType,
175
+ sequenceType,
176
+ });
177
+ }
178
+
179
+ app.model.args.importColumns = importColumns;
180
+ if (!importColumns.some((c) => c.sequenceType !== undefined)) {
181
+ app.model.ui.fileImportError = 'No sequence columns found';
182
+ return;
183
+ }
184
+ }
package/src/main.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { BlockLayout } from '@platforma-sdk/ui-vue';
2
+ import '@platforma-sdk/ui-vue/styles';
3
+ import { createApp } from 'vue';
4
+ import { sdkPlugin } from './app';
5
+
6
+ createApp(BlockLayout).use(sdkPlugin).mount('#app');
@@ -0,0 +1,191 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ ImportFileHandle,
4
+ LocalImportFileHandle,
5
+ PlRef,
6
+ } from '@platforma-sdk/model';
7
+ import {
8
+ plRefsEqual,
9
+ } from '@platforma-sdk/model';
10
+ import type {
11
+ PlAgDataTableSettings,
12
+ } from '@platforma-sdk/ui-vue';
13
+ import {
14
+ PlAgDataTableToolsPanel,
15
+ PlAgDataTableV2,
16
+ PlBlockPage,
17
+ PlBtnGhost,
18
+ PlDropdown,
19
+ PlDropdownRef,
20
+ PlFileInput,
21
+ PlMaskIcon24,
22
+ PlNumberField,
23
+ PlSectionSeparator,
24
+ PlSlideModal,
25
+ } from '@platforma-sdk/ui-vue';
26
+ import {
27
+ computed,
28
+ ref,
29
+ } from 'vue';
30
+ import {
31
+ useApp,
32
+ } from '../app';
33
+
34
+ import { importFile } from '../importFile';
35
+
36
+ const app = useApp();
37
+
38
+ function setDataset(ref: PlRef | undefined) {
39
+ app.model.args.datasetRef = ref;
40
+ app.model.ui.title = 'Immune Assay Data - ' + (ref
41
+ ? app.model.outputs.datasetOptions?.find((o) =>
42
+ plRefsEqual(o.ref, ref),
43
+ )?.label
44
+ : '');
45
+ }
46
+ const settingsOpen = ref(app.model.args.datasetRef === undefined);
47
+
48
+ const tableSettings = computed<PlAgDataTableSettings>(() => {
49
+ const pTable = app.model.outputs.table;
50
+
51
+ if (pTable === undefined && !app.model.outputs.isRunning) {
52
+ // special case: when block is not yet started at all (no table calculated)
53
+ return undefined;
54
+ }
55
+
56
+ return {
57
+ sourceType: 'ptable',
58
+ model: pTable,
59
+ };
60
+ });
61
+
62
+ const setFile = async (file: ImportFileHandle | undefined) => {
63
+ if (!file) {
64
+ return;
65
+ }
66
+ importFile(file as LocalImportFileHandle);
67
+ };
68
+
69
+ const sequenceColumnOptions = computed(() => {
70
+ return app.model.args.importColumns
71
+ ?.filter((c) => c.sequenceType !== undefined)
72
+ ?.map((c) => ({
73
+ label: c.header,
74
+ value: c.header,
75
+ }));
76
+ });
77
+
78
+ const similarityTypeOptions = [
79
+ { label: 'Alignment Score', value: 'alignment-score' },
80
+ { label: 'Sequence Identity', value: 'sequence-identity' },
81
+ ];
82
+
83
+ const coverageModeOptions = [
84
+ { label: 'Coverage of clone and assay sequences', value: 0 },
85
+ { label: 'Coverage of assay sequence', value: 1 },
86
+ { label: 'Coverage of clone sequence', value: 2 },
87
+ { label: 'Target length ≥ x% of clone length', value: 3 },
88
+ { label: 'Query length ≥ x% of assay sequence length', value: 4 },
89
+ { label: 'Shorter sequence ≥ x% of longer', value: 5 },
90
+ ];
91
+ </script>
92
+
93
+ <template>
94
+ <PlBlockPage>
95
+ <template #title>
96
+ {{ app.model.ui.title }}
97
+ </template>
98
+ <template #append>
99
+ <PlAgDataTableToolsPanel>
100
+ <!-- <PlMultiSequenceAlignment
101
+ v-model="app.model.ui.alignmentModel"
102
+ :label-column-option-predicate="isLabelColumnOption"
103
+ :sequence-column-predicate="isSequenceColumn"
104
+ :linker-column-predicate="isLinkerColumn"
105
+ :p-frame="app.model.outputs.pf"
106
+ :selection="selection"
107
+ /> -->
108
+ </PlAgDataTableToolsPanel>
109
+ <PlBtnGhost @click.stop="() => (settingsOpen = true)">
110
+ Settings
111
+ <template #append>
112
+ <PlMaskIcon24 name="settings" />
113
+ </template>
114
+ </PlBtnGhost>
115
+ </template>
116
+ <PlAgDataTableV2
117
+ v-model="app.model.ui.tableState"
118
+ :settings="tableSettings"
119
+ show-columns-panel
120
+ show-export-button
121
+ />
122
+ <PlSlideModal v-model="settingsOpen" :close-on-outside-click="false">
123
+ <template #title>Settings</template>
124
+ <PlDropdownRef
125
+ :model-value="app.model.args.datasetRef"
126
+ :options="app.model.outputs.datasetOptions"
127
+ label="Dataset"
128
+ clearable
129
+ required
130
+ @update:model-value="setDataset"
131
+ />
132
+ <PlDropdown
133
+ v-model="app.model.args.targetRef"
134
+ :options="app.model.outputs.targetOptions"
135
+ label="Clonotype sequence to match"
136
+ clearable
137
+ required
138
+ >
139
+ <template #tooltip>
140
+ Select the sequence column used to match the assay data sequence with. If you select amino acid sequence and
141
+ the assay data sequence is nucleotide, the assay data sequence will be converted to amino acid sequence automatically.
142
+ </template>
143
+ </PlDropdown>
144
+ <PlFileInput
145
+ v-model="app.model.args.fileHandle"
146
+ label="Assay data to import"
147
+ placeholder="Assay data table"
148
+ :extensions="['.csv', '.xlsx', '.xls', '.tsv']"
149
+ :error="app.model.ui.fileImportError"
150
+ required
151
+ @update:model-value="setFile"
152
+ />
153
+ <!-- @TODO: delete this after bug with not working error message in PlFileInput is fixed -->
154
+ <span v-if="app.model.ui.fileImportError" style="color: red;">
155
+ {{ app.model.ui.fileImportError }}
156
+ </span>
157
+
158
+ <PlDropdown
159
+ v-model="app.model.args.sequenceColumnHeader"
160
+ :options="sequenceColumnOptions"
161
+ label="Assay sequence column"
162
+ placeholder="Sequence column"
163
+ clearable
164
+ required
165
+ />
166
+
167
+ <PlSectionSeparator>Matching parameters</PlSectionSeparator>
168
+ <PlDropdown
169
+ v-model="app.model.args.settings.coverageMode"
170
+ :options="coverageModeOptions"
171
+ label="Coverage Mode"
172
+ >
173
+ <template #tooltip>
174
+ How to calculate the coverage between sequences for the coverage threshold.
175
+ </template>
176
+ </PlDropdown>
177
+
178
+ <PlNumberField
179
+ v-model="app.model.args.settings.coverageThreshold"
180
+ label="Coverage Threshold"
181
+ :minValue="0.1"
182
+ :step="0.1"
183
+ :maxValue="1.0"
184
+ >
185
+ <template #tooltip>
186
+ Select min fraction of aligned (covered) residues of clonotypes in the cluster.
187
+ </template>
188
+ </PlNumberField>
189
+ </PlSlideModal>
190
+ </PlBlockPage>
191
+ </template>
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": false,
4
+ "target": "ES2020",
5
+ "useDefineForClassFields": true,
6
+ "module": "ESNext",
7
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "preserve",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ // "noUnusedLocals": true,
22
+ // "noUnusedParameters": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ },
25
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/shims.d.ts"]
26
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ {
5
+ "path": "./tsconfig.app.json"
6
+ },
7
+ {
8
+ "path": "./tsconfig.node.json"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": false,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true,
9
+ "noEmit": true
10
+ },
11
+ "include": ["vite.config.ts"]
12
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vite';
2
+ import vue from '@vitejs/plugin-vue';
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [vue()],
7
+ base: './',
8
+ build: {
9
+ sourcemap: true,
10
+ },
11
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ watch: false
6
+ }
7
+ });