@abi-software/map-utilities 1.2.0-beta.8 → 1.2.1-beta.0

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,673 @@
1
+ <template>
2
+ <div class="connectivity-graph" v-loading="loading">
3
+
4
+ <div ref="graphCanvas" class="graph-canvas"></div>
5
+
6
+ <div class="control-panel control-panel-tools">
7
+ <div class="tools" :class="{'zoom-locked': zoomEnabled}">
8
+ <el-tooltip
9
+ :content="resetLabel"
10
+ placement="top"
11
+ effect="control-tooltip"
12
+ >
13
+ <el-button
14
+ class="control-button"
15
+ :class="theme"
16
+ size="small"
17
+ @click="reset"
18
+ >
19
+ <el-icon color="white">
20
+ <el-icon-aim />
21
+ </el-icon>
22
+ <span class="visually-hidden">{{ resetLabel }}</span>
23
+ </el-button>
24
+ </el-tooltip>
25
+
26
+ <el-tooltip
27
+ :content="zoomLockLabel"
28
+ placement="top"
29
+ effect="control-tooltip"
30
+ >
31
+ <el-button
32
+ class="control-button"
33
+ :class="theme"
34
+ size="small"
35
+ @click="toggleZoom"
36
+ >
37
+ <el-icon color="white">
38
+ <template v-if="zoomEnabled">
39
+ <el-icon-lock />
40
+ </template>
41
+ <template v-else>
42
+ <el-icon-unlock />
43
+ </template>
44
+ </el-icon>
45
+ <span class="visually-hidden">{{ zoomLockLabel }}</span>
46
+ </el-button>
47
+ </el-tooltip>
48
+
49
+ <el-tooltip
50
+ :content="zoomInLabel"
51
+ placement="left"
52
+ effect="control-tooltip"
53
+ >
54
+ <el-button
55
+ class="control-button"
56
+ :class="theme"
57
+ size="small"
58
+ @click="zoomIn"
59
+ >
60
+ <el-icon color="white">
61
+ <el-icon-zoom-in />
62
+ </el-icon>
63
+ <span class="visually-hidden">{{ zoomInLabel }}</span>
64
+ </el-button>
65
+ </el-tooltip>
66
+
67
+ <el-tooltip
68
+ :content="zoomOutLabel"
69
+ placement="left"
70
+ effect="control-tooltip"
71
+ >
72
+ <el-button
73
+ class="control-button"
74
+ :class="theme"
75
+ size="small"
76
+ @click="zoomOut"
77
+ >
78
+ <el-icon color="white">
79
+ <el-icon-zoom-out />
80
+ </el-icon>
81
+ <span class="visually-hidden">{{ zoomOutLabel }}</span>
82
+ </el-button>
83
+ </el-tooltip>
84
+ </div>
85
+ </div>
86
+
87
+ <div class="control-panel control-panel-nodes">
88
+ <div class="node-key">
89
+ <!-- <div class="key-head">Node type:</div> -->
90
+ <div class="key-box-container">
91
+ <div class="key-box key-box-dendrite">
92
+ Origin
93
+ </div>
94
+ <div class="key-box key-box-node">
95
+ Components
96
+ </div>
97
+ <div class="key-box key-box-axon">
98
+ Destination
99
+ </div>
100
+ <!--
101
+ <div class="key-box key-box-both">
102
+ Both
103
+ </div>
104
+ -->
105
+ </div>
106
+ </div>
107
+ </div>
108
+
109
+ <div class="connectivity-graph-error" v-if="errorMessage || errorConnectivities">
110
+ <strong v-if="errorConnectivities">{{ errorConnectivities }}</strong>
111
+ {{ errorMessage }}
112
+ </div>
113
+
114
+ </div>
115
+ </template>
116
+
117
+ <script>
118
+ import { ConnectivityGraph } from './graph';
119
+ import { capitalise } from '../utilities';
120
+
121
+ const MIN_SCHEMA_VERSION = 1.3;
122
+ const CACHE_LIFETIME = 24 * 60 * 60 * 1000; // One day
123
+ const RESET_LABEL = 'Reset position';
124
+ const ZOOM_LOCK_LABEL = 'Lock zoom';
125
+ const ZOOM_UNLOCK_LABEL = 'Unlock zoom';
126
+ const ZOOM_IN_LABEL = 'Zoom in';
127
+ const ZOOM_OUT_LABEL = 'Zoom out';
128
+ const ZOOM_INCREMENT = 0.25;
129
+ const APP_PRIMARY_COLOR = '#8300bf';
130
+ const ERROR_TIMEOUT = 3000; // 3 seconds
131
+
132
+ export default {
133
+ name: 'ConnectivityGraph',
134
+ props: {
135
+ /**
136
+ * Entity to load its connectivity graph.
137
+ */
138
+ entry: {
139
+ type: String,
140
+ default: '',
141
+ },
142
+ mapServer: {
143
+ type: String,
144
+ default: '',
145
+ },
146
+ selectedConnectivityData: {
147
+ type: Array,
148
+ default: [],
149
+ },
150
+ },
151
+ data: function () {
152
+ return {
153
+ loading: true,
154
+ connectivityGraph: null,
155
+ selectedSource: '',
156
+ pathList: [],
157
+ schemaVersion: '',
158
+ knowledgeByPath: new Map(),
159
+ labelledTerms: new Set(),
160
+ labelCache: new Map(),
161
+ resetLabel: RESET_LABEL,
162
+ zoomLockLabel: ZOOM_LOCK_LABEL,
163
+ zoomInLabel: ZOOM_IN_LABEL,
164
+ zoomOutLabel: ZOOM_OUT_LABEL,
165
+ iconColor: APP_PRIMARY_COLOR,
166
+ zoomEnabled: false,
167
+ errorMessage: '',
168
+ errorConnectivities: '',
169
+ };
170
+ },
171
+ mounted() {
172
+ this.refreshCache();
173
+ this.loadCacheData();
174
+ this.run().then((res) => {
175
+ this.showGraph(this.entry);
176
+ });
177
+ },
178
+ methods: {
179
+ loadCacheData: function () {
180
+ const selectedSource = sessionStorage.getItem('connectivity-graph-source');
181
+ const labelCache = sessionStorage.getItem('connectivity-graph-labels');
182
+ const pathList = sessionStorage.getItem('connectivity-graph-pathlist');
183
+ const schemaVersion = sessionStorage.getItem('connectivity-graph-schema-version');
184
+
185
+ if (selectedSource) {
186
+ this.selectedSource = selectedSource;
187
+ }
188
+ if (pathList) {
189
+ this.pathList = JSON.parse(pathList);
190
+ }
191
+ if (labelCache) {
192
+ const labelCacheObj = JSON.parse(labelCache);
193
+ this.labelCache = new Map(Object.entries(labelCacheObj));
194
+ }
195
+ if (schemaVersion) {
196
+ this.schemaVersion = schemaVersion;
197
+ }
198
+ },
199
+ removeAllCacheData: function () {
200
+ const keys = [
201
+ 'connectivity-graph-expiry',
202
+ 'connectivity-graph-source',
203
+ 'connectivity-graph-labels',
204
+ 'connectivity-graph-pathlist',
205
+ 'connectivity-graph-schema-version',
206
+ ];
207
+ keys.forEach((key) => {
208
+ sessionStorage.removeItem(key);
209
+ });
210
+ },
211
+ refreshCache: function () {
212
+ const expiry = sessionStorage.getItem('connectivity-graph-expiry');
213
+ const now = new Date();
214
+
215
+ if (now.getTime() > expiry) {
216
+ this.removeAllCacheData();
217
+ }
218
+ },
219
+ updateCacheExpiry: function () {
220
+ const now = new Date();
221
+ const expiry = now.getTime() + CACHE_LIFETIME;
222
+
223
+ sessionStorage.setItem('connectivity-graph-expiry', expiry);
224
+ },
225
+ run: async function () {
226
+ if (!this.schemaVersion) {
227
+ this.schemaVersion = await this.getSchemaVersion();
228
+ sessionStorage.setItem('connectivity-graph-schema-version', this.schemaVersion);
229
+ this.updateCacheExpiry();
230
+ }
231
+ if (this.schemaVersion < MIN_SCHEMA_VERSION) {
232
+ console.warn('No Server!');
233
+ return;
234
+ }
235
+ this.showSpinner();
236
+ if (!this.selectedSource) {
237
+ this.selectedSource = await this.setSourceList();
238
+ sessionStorage.setItem('connectivity-graph-source', this.selectedSource);
239
+ this.updateCacheExpiry();
240
+ }
241
+ await this.setPathList(this.selectedSource);
242
+ this.hideSpinner();
243
+ },
244
+ showGraph: async function (neuronPath) {
245
+ const graphCanvas = this.$refs.graphCanvas;
246
+
247
+ this.showSpinner();
248
+
249
+ this.connectivityGraph = new ConnectivityGraph(this.labelCache, graphCanvas);
250
+ await this.connectivityGraph.addConnectivity(this.knowledgeByPath.get(neuronPath));
251
+
252
+ this.hideSpinner();
253
+
254
+ this.connectivityGraph.showConnectivity(graphCanvas);
255
+
256
+ // saved state from list view
257
+ if (this.selectedConnectivityData.length) {
258
+ this.connectivityGraph.selectConnectivity(this.selectedConnectivityData);
259
+ }
260
+
261
+ this.connectivityGraph.on('tap-node', (event) => {
262
+ const data = event.detail;
263
+ /**
264
+ * This event is triggered after a node on the connectivity graph is clicked.
265
+ */
266
+ this.$emit('tap-node', data);
267
+ });
268
+ },
269
+ query: async function (sql, params) {
270
+ const url = `${this.mapServer}knowledge/query/`;
271
+ const query = { sql, params };
272
+
273
+ try {
274
+ const response = await fetch(url, {
275
+ method: 'POST',
276
+ headers: {
277
+ "Accept": "application/json; charset=utf-8",
278
+ "Cache-Control": "no-store",
279
+ "Content-Type": "application/json"
280
+ },
281
+ body: JSON.stringify(query)
282
+ });
283
+
284
+ if (!response.ok) {
285
+ throw new Error(`Cannot access ${url}`);
286
+ }
287
+
288
+ return await response.json();
289
+ } catch {
290
+ return {
291
+ values: []
292
+ };
293
+ }
294
+ },
295
+ setSourceList: async function () {
296
+ const data = await this.getJsonData(`${this.mapServer}knowledge/sources`);
297
+ const sources = data ? (data.sources || []) : [];
298
+
299
+ // Order with most recent first...
300
+ let firstSource = '';
301
+ const sourceList = [];
302
+
303
+ for (const source of sources) {
304
+ if (source) {
305
+ sourceList.push(source);
306
+
307
+ if (firstSource === '') {
308
+ firstSource = source;
309
+ }
310
+ }
311
+ }
312
+
313
+ return firstSource;
314
+ },
315
+ loadPathData: async function (source) {
316
+ const data = await this.query(
317
+ `select entity, knowledge from knowledge
318
+ where entity like 'ilxtr:%' and source=?
319
+ order by entity`,
320
+ [source]);
321
+ const pathList = data ? data.values : [];
322
+ return pathList;
323
+ },
324
+ setPathList: async function (source) {
325
+ if (!this.pathList.length) {
326
+ this.pathList = await this.loadPathData(source);
327
+ sessionStorage.setItem('connectivity-graph-pathlist', JSON.stringify(this.pathList));
328
+ this.updateCacheExpiry();
329
+ }
330
+
331
+ this.knowledgeByPath.clear();
332
+ this.labelledTerms = new Set();
333
+
334
+ for (const [key, jsonKnowledge] of this.pathList) {
335
+ const knowledge = JSON.parse(jsonKnowledge);
336
+ if ('connectivity' in knowledge) {
337
+ this.knowledgeByPath.set(key, knowledge);
338
+ this.cacheLabels(knowledge);
339
+ }
340
+ }
341
+
342
+ if (!this.labelCache.size) {
343
+ await this.getCachedTermLabels();
344
+ }
345
+
346
+ return '';
347
+ },
348
+ getSchemaVersion: async function () {
349
+ const data = await this.getJsonData(`${this.mapServer}knowledge/schema-version`);
350
+ return data ? (+data.version || 0) : 0;
351
+ },
352
+ getJsonData: async function (url) {
353
+ try {
354
+ const response = await fetch(url, {
355
+ method: 'GET',
356
+ headers: {
357
+ "Accept": "application/json; charset=utf-8",
358
+ "Cache-Control": "no-store",
359
+ "Content-Type": "application/json"
360
+ }
361
+ });
362
+
363
+ if (!response.ok) {
364
+ console.error(`Cannot access ${url}`);
365
+ }
366
+
367
+ return await response.json();
368
+ } catch {
369
+ return null;
370
+ }
371
+ },
372
+ getCachedTermLabels: async function () {
373
+ if (this.labelledTerms.size) {
374
+ const data = await this.query(
375
+ `select entity, knowledge from knowledge
376
+ where entity in (?${', ?'.repeat(this.labelledTerms.size-1)})
377
+ order by source desc`,
378
+ [...this.labelledTerms.values()]
379
+ );
380
+
381
+ let last_entity = null;
382
+ for (const [key, jsonKnowledge] of data.values) {
383
+ if (key !== last_entity) {
384
+ const knowledge = JSON.parse(jsonKnowledge);
385
+ this.labelCache.set(key, knowledge['label'] || key);
386
+ last_entity = key;
387
+ }
388
+ }
389
+
390
+ const labelCacheObj = Object.fromEntries(this.labelCache);
391
+ sessionStorage.setItem('connectivity-graph-labels', JSON.stringify(labelCacheObj));
392
+ this.updateCacheExpiry();
393
+ }
394
+ },
395
+ cacheNodeLabels: function (node) {
396
+ for (const term of [node[0], ...node[1]]) {
397
+ this.labelledTerms.add(term);
398
+ }
399
+ },
400
+ cacheLabels: async function (knowledge) {
401
+ for (const edge of knowledge.connectivity) {
402
+ this.cacheNodeLabels(edge[0]);
403
+ this.cacheNodeLabels(edge[1]);
404
+ }
405
+ },
406
+ showSpinner: function () {
407
+ this.loading = true;
408
+ },
409
+ hideSpinner: function () {
410
+ this.loading = false;
411
+ },
412
+ reset: function () {
413
+ this.connectivityGraph.reset();
414
+ },
415
+ zoomIn: function () {
416
+ this.connectivityGraph.zoom(ZOOM_INCREMENT);
417
+ },
418
+ zoomOut: function () {
419
+ this.connectivityGraph.zoom(-ZOOM_INCREMENT);
420
+ },
421
+ /**
422
+ * Enable/disable user zoom for scrolling
423
+ */
424
+ toggleZoom: function () {
425
+ this.zoomEnabled = !this.zoomEnabled;
426
+ this.zoomLockLabel = this.zoomEnabled ? ZOOM_UNLOCK_LABEL : ZOOM_LOCK_LABEL;
427
+ this.connectivityGraph.enableZoom(!this.zoomEnabled);
428
+ },
429
+ getErrorConnectivities: function (errorData) {
430
+ const errorDataToEmit = [...new Set(errorData)];
431
+ let errorConnectivities = '';
432
+
433
+ errorDataToEmit.forEach((connectivity, i) => {
434
+ const { label } = connectivity;
435
+ errorConnectivities += (i === 0) ? capitalise(label) : label;
436
+
437
+ if (errorDataToEmit.length > 1) {
438
+ if ((i + 2) === errorDataToEmit.length) {
439
+ errorConnectivities += ' and ';
440
+ } else if ((i + 1) < errorDataToEmit.length) {
441
+ errorConnectivities += ', ';
442
+ }
443
+ }
444
+ });
445
+
446
+ return errorConnectivities;
447
+ },
448
+ /**
449
+ * Function to show error message.
450
+ * `errorInfo` includes `errorData` array (optional) for error connectivities
451
+ * and `errorMessage` for error message.
452
+ * @arg `errorInfo`
453
+ */
454
+ showErrorMessage: function (errorInfo) {
455
+ const { errorData, errorMessage } = errorInfo;
456
+ this.errorConnectivities = this.getErrorConnectivities(errorData);
457
+ this.errorMessage = errorMessage;
458
+
459
+ // Show error for 3 seconds
460
+ setTimeout(() => {
461
+ this.errorConnectivities = '';
462
+ this.errorMessage = '';
463
+ }, ERROR_TIMEOUT);
464
+ },
465
+ },
466
+ };
467
+ </script>
468
+
469
+ <style lang="scss" scoped>
470
+ .connectivity-graph,
471
+ .graph-canvas {
472
+ width: 100%;
473
+ height: 600px;
474
+ background-color: white;
475
+ position: relative;
476
+ }
477
+
478
+ .connectivity-graph {
479
+ box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.06);
480
+ border: solid 1px #e4e7ed;
481
+ }
482
+
483
+ .control-panel {
484
+ position: absolute;
485
+ right: 1rem;
486
+
487
+ &-tools {
488
+ top: 1rem;
489
+ }
490
+
491
+ &-nodes {
492
+ bottom: 1rem;
493
+ }
494
+ }
495
+
496
+ .node-key {
497
+ padding: 0.5rem;
498
+ font-size: 12px;
499
+ border: 1px solid var(--el-border-color);
500
+ background-color: rgba(#f7faff, 0.85);
501
+ }
502
+
503
+ .key-head {
504
+ text-align: center;
505
+ font-weight: bold;
506
+ border-bottom: 1px solid var(--el-border-color);
507
+ padding-bottom: 4px;
508
+ margin-bottom: 0.5rem;
509
+ }
510
+
511
+ .key-box-container {
512
+ display: flex;
513
+ flex-direction: row;
514
+ gap: 1rem;
515
+ }
516
+
517
+ .key-box {
518
+ display: flex;
519
+ align-items: center;
520
+ gap: 0.35rem;
521
+ position: relative;
522
+ line-height: 1;
523
+
524
+ &::before {
525
+ content: "";
526
+ display: block;
527
+ width: 14px;
528
+ height: 14px;
529
+ }
530
+
531
+ &-node::before,
532
+ &-both::before {
533
+ border: 1px solid gray;
534
+ border-radius: var(--el-border-radius-small);
535
+ }
536
+
537
+ // &-node {
538
+ // background: #80F0F0;
539
+ // }
540
+
541
+ // &-both {
542
+ // background: gray;
543
+ // }
544
+
545
+ &-axon::before {
546
+ border: 1px solid gray;
547
+ border-radius: var(--el-border-radius-small);
548
+ transform: rotate(45deg);
549
+ // background: green;
550
+ }
551
+
552
+ &-dendrite::before {
553
+ border: 1px solid gray;
554
+ border-radius: 50%;
555
+ // background: red;
556
+ }
557
+ }
558
+
559
+ .tools {
560
+ display: grid;
561
+ grid-template-columns: repeat(2, 1fr);
562
+ grid-template-rows: repeat(3, 1fr);
563
+ gap: 0.5rem;
564
+
565
+ :deep(.el-button:nth-child(3)) {
566
+ grid-column: 2;
567
+ grid-row: 2;
568
+ }
569
+
570
+ :deep(.el-button:nth-child(4)) {
571
+ grid-column: 2;
572
+ grid-row: 3;
573
+ }
574
+
575
+ :deep(.el-button:nth-child(3)),
576
+ :deep(.el-button:nth-child(4)) {
577
+ opacity: 0;
578
+ visibility: hidden;
579
+ pointer-events: none;
580
+ transform: translateY(-100%);
581
+ transition: all 0.25s ease;
582
+ }
583
+
584
+ &.zoom-locked {
585
+ :deep(.el-button:nth-child(3)),
586
+ :deep(.el-button:nth-child(4)) {
587
+ opacity: 1;
588
+ visibility: visible;
589
+ pointer-events: initial;
590
+ transform: translateY(0%);
591
+ }
592
+
593
+ :deep(.el-button:nth-child(4)) {
594
+ transition-delay: 0.125s;
595
+ }
596
+ }
597
+ }
598
+
599
+ .control-button {
600
+ width: 24px;
601
+ height: 24px;
602
+ margin: 0 !important;
603
+ padding: 0 !important;
604
+ font-size: 16px !important;
605
+ border-color: $app-primary-color !important;
606
+ border-radius: 50%;
607
+ background: $app-primary-color !important;
608
+ transition: all 0.25s ease;
609
+
610
+ svg {
611
+ margin: 0;
612
+ }
613
+
614
+ &,
615
+ &:focus,
616
+ &:active {
617
+ box-shadow: none !important;
618
+ }
619
+ }
620
+
621
+ :deep(.cy-graph-tooltip) {
622
+ padding: 4px 10px;
623
+ font-family: Asap;
624
+ font-size: 12px;
625
+ background: #f3ecf6 !important;
626
+ border: 1px solid $app-primary-color;
627
+ border-radius: var(--el-border-radius-base);
628
+ box-shadow: 1px 1px 6px 1px rgba($app-primary-color, 0.15);
629
+ position: relative;
630
+ top: 0;
631
+ left: 0;
632
+ width: fit-content;
633
+ z-index: 1;
634
+ }
635
+
636
+ .connectivity-graph-error {
637
+ position: absolute;
638
+ top: 1rem;
639
+ left: 50%;
640
+ transform: translateX(-50%);
641
+ width: fit-content;
642
+ font-size: 12px;
643
+ padding: 0.25rem 0.5rem;
644
+ background-color: var(--el-color-error-light-9);
645
+ border-radius: var(--el-border-radius-small);
646
+ border: 1px solid var(--el-color-error);
647
+ }
648
+
649
+ .visually-hidden {
650
+ clip: rect(0 0 0 0);
651
+ clip-path: inset(50%);
652
+ height: 1px;
653
+ overflow: hidden;
654
+ position: absolute;
655
+ white-space: nowrap;
656
+ width: 1px;
657
+ }
658
+ </style>
659
+
660
+ <style lang="scss">
661
+ .el-popper.is-control-tooltip {
662
+ padding: 4px 10px;
663
+ font-family: Asap;
664
+ background: #f3ecf6 !important;
665
+ border: 1px solid $app-primary-color;
666
+
667
+ & .el-popper__arrow::before {
668
+ border: 1px solid;
669
+ border-color: $app-primary-color;
670
+ background: #f3ecf6;
671
+ }
672
+ }
673
+ </style>