Skip to content

Commit e4722a6

Browse files
committed
Add "Related software" tab to container details view
1 parent aee10b3 commit e4722a6

File tree

5 files changed

+321
-3
lines changed

5 files changed

+321
-3
lines changed

src/app/features/containers/pages/container/container.component.css

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,134 @@ table th {
428428
height: 38px;
429429
}
430430
}
431+
432+
/* Related Software Section */
433+
.related-software {
434+
display: flex;
435+
flex-direction: column;
436+
gap: 24px;
437+
}
438+
439+
.related-software-header {
440+
h3 {
441+
font-size: 1.5rem;
442+
font-weight: 600;
443+
margin-bottom: 8px;
444+
}
445+
446+
p {
447+
color: rgba(var(--main-font-color), 0.85);
448+
line-height: 1.5;
449+
}
450+
}
451+
452+
.related-software-list {
453+
display: flex;
454+
flex-direction: column;
455+
gap: 12px;
456+
}
457+
458+
.software-item {
459+
border: 1px solid rgba(var(--main-font-color), 0.2);
460+
border-radius: 8px;
461+
padding: 16px;
462+
background-color: rgb(var(--color-surface-container-lowest));
463+
transition: border-color 0.2s, box-shadow 0.2s;
464+
465+
&:hover {
466+
border-color: rgba(var(--picton-blue-600), 0.4);
467+
box-shadow: 0 2px 8px rgba(var(--picton-blue-600), 0.1);
468+
}
469+
}
470+
471+
:host-context(.dark) .software-item:hover {
472+
border-color: rgba(var(--picton-blue-300), 0.4);
473+
box-shadow: 0 2px 8px rgba(var(--picton-blue-400), 0.1);
474+
}
475+
476+
.software-info {
477+
display: flex;
478+
align-items: center;
479+
justify-content: space-between;
480+
flex-wrap: wrap;
481+
gap: 12px;
482+
margin-bottom: 12px;
483+
}
484+
485+
.software-name {
486+
font-size: 1.125rem;
487+
font-weight: 600;
488+
489+
&.bdip-link {
490+
display: inline-flex;
491+
align-items: center;
492+
gap: 6px;
493+
color: rgb(var(--picton-blue-600));
494+
text-decoration: none;
495+
transition: color 0.2s;
496+
497+
&:hover {
498+
color: rgb(var(--picton-blue-700));
499+
text-decoration: underline;
500+
}
501+
}
502+
}
503+
504+
:host-context(.dark) .software-name.bdip-link {
505+
color: rgb(var(--picton-blue-300));
506+
507+
&:hover {
508+
color: rgb(var(--picton-blue-200));
509+
}
510+
}
511+
512+
.cooccurrence-count {
513+
font-size: 0.875rem;
514+
color: rgba(var(--main-font-color), 0.7);
515+
padding: 4px 12px;
516+
background-color: rgba(var(--main-font-color), 0.07);
517+
border-radius: 1rem;
518+
}
519+
520+
.article-links {
521+
display: flex;
522+
flex-wrap: wrap;
523+
gap: 8px;
524+
}
525+
526+
.article-link {
527+
display: inline-block;
528+
padding: 4px 10px;
529+
font-size: 0.75rem;
530+
border-radius: 4px;
531+
background-color: rgba(var(--main-font-color), 0.05);
532+
color: rgb(var(--main-font-color));
533+
text-decoration: none;
534+
transition: background-color 0.2s, color 0.2s;
535+
border: 1px solid rgba(var(--main-font-color), 0.1);
536+
537+
&:hover {
538+
background-color: rgba(var(--picton-blue-500), 0.1);
539+
color: rgb(var(--picton-blue-700));
540+
border-color: rgba(var(--picton-blue-500), 0.4);
541+
}
542+
}
543+
544+
:host-context(.dark) .article-link:hover {
545+
background-color: rgba(var(--picton-blue-300), 0.1);
546+
color: rgb(var(--picton-blue-300));
547+
border-color: rgba(var(--picton-blue-300), 0.3);
548+
}
549+
550+
.related-software-empty {
551+
display: flex;
552+
align-items: center;
553+
justify-content: center;
554+
padding: 48px 24px;
555+
text-align: center;
556+
color: rgba(var(--main-font-color), 0.6);
557+
font-size: 1rem;
558+
border: 1px dashed rgba(var(--main-font-color), 0.2);
559+
border-radius: 8px;
560+
background-color: rgba(var(--main-font-color), 0.02);
561+
}

src/app/features/containers/pages/container/container.component.html

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ <h2>{{ container.name }}</h2>
118118
{ id: TabName.README, label: 'README', active: selectedTab() === TabName.README },
119119
{ id: TabName.TAGS, label: TabName.TAGS, active: selectedTab() === TabName.TAGS },
120120
{ id: TabName.TESTING, label: 'Testing', active: selectedTab() === TabName.TESTING },
121-
{ id: TabName.DOCKERFILE, label: 'Dockerfile', active: selectedTab() === TabName.DOCKERFILE }
121+
{ id: TabName.DOCKERFILE, label: 'Dockerfile', active: selectedTab() === TabName.DOCKERFILE },
122+
{ id: TabName.RELATED_SOFTWARE, label: 'Related software', active: selectedTab() === TabName.RELATED_SOFTWARE }
122123
]" />
123124
@if (selectedTab() === TabName.TESTING) {
124125
<app-dropdown [items]="containerPlatforms" [(selected)]="selectedContainerPlatform"/>
@@ -136,6 +137,8 @@ <h2>{{ container.name }}</h2>
136137
<ng-container *ngTemplateOutlet="testing; context: { containerMetadata: containerMetadata }"></ng-container>
137138
} @else if (selectedTab() === 'dockerfile') {
138139
<ng-container *ngTemplateOutlet="dockerfile"></ng-container>
140+
} @else if (selectedTab() === 'related-software') {
141+
<ng-container *ngTemplateOutlet="relatedSoftware"></ng-container>
139142
}
140143
</div>
141144
} @else if (status === Status.LOADING) {
@@ -261,3 +264,43 @@ <h3>{{ tag.name }}</h3>
261264
```
262265
</markdown>
263266
</ng-template>
267+
268+
<ng-template #relatedSoftware>
269+
@if (this.relatedSoftware() && getSortedRelatedSoftware().length > 0) {
270+
<div class="related-software">
271+
<div class="related-software-header">
272+
<h3>Software that co-occurs with {{ container!.name }} in Bio-Protocol articles</h3>
273+
<p>This list shows software tools that appear together with {{ container!.name }} in scientific protocols from Bio-Protocol. Software available in BDIP is highlighted and linked for easy access.</p>
274+
</div>
275+
<div class="related-software-list">
276+
@for (software of getSortedRelatedSoftware(); track software.name) {
277+
<div class="software-item">
278+
<div class="software-info">
279+
@if (software.inBdip) {
280+
<a [routerLink]="['/container', software.name]" class="software-name bdip-link">
281+
{{ software.displayName }}
282+
</a>
283+
} @else {
284+
<span class="software-name">{{ software.displayName }}</span>
285+
}
286+
<span class="cooccurrence-count">{{ software.count }} {{ software.count === 1 ? 'article' : 'articles' }}</span>
287+
</div>
288+
<div class="article-links">
289+
@for (articleId of getCooccurringArticles(software.name); track articleId) {
290+
<a [href]="getBioProtocolArticleUrl(articleId)" target="_blank" class="article-link">
291+
{{ articleId }}
292+
</a>
293+
}
294+
</div>
295+
</div>
296+
}
297+
</div>
298+
</div>
299+
} @else if (this.relatedSoftware() && getSortedRelatedSoftware().length === 0) {
300+
<div class="related-software-empty">
301+
<p>No related software information is available for {{ container!.name }} yet.</p>
302+
</div>
303+
} @else {
304+
<app-loading />
305+
}
306+
</ng-template>

src/app/features/containers/pages/container/container.component.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ import { BytesToSizePipe } from "../../../../shared/pipes/bytes-to-size/bytes-to
1212
import { SvgIconComponent } from 'angular-svg-icon';
1313
import { LoadingComponent } from "../../../../shared/components/loading/loading.component";
1414
import { ImageMetadata } from "../../../../models/image-metadata";
15-
import { ReplacePipe } from "../../../../shared/pipes/replace/replace.pipe";
1615
import { TermStanza } from "../../../../obo/TermStanza";
1716
import { DockerfileService } from "../../../../services/dockerfile.service";
1817
import { DropdownComponent } from "../../../../shared/components/dropdown/dropdown.component";
18+
import { RelatedSoftware } from "../../../../models/related-software";
1919

2020
@Component({
2121
selector: 'app-container',
2222
templateUrl: './container.component.html',
2323
styleUrl: './container.component.css',
2424
host: { '[class.dark]': 'isDarkTheme()' },
25-
imports: [DatePipe, SlicePipe, MarkdownModule, TabsComponent, BytesToSizePipe, ClipboardButtonComponent, SvgIconComponent, LoadingComponent, NgTemplateOutlet, ReplacePipe, RouterLink, DropdownComponent]
25+
imports: [DatePipe, SlicePipe, MarkdownModule, TabsComponent, BytesToSizePipe, ClipboardButtonComponent, SvgIconComponent, LoadingComponent, NgTemplateOutlet, RouterLink, DropdownComponent]
2626
})
2727
export class ContainerComponent {
2828
/* Services */
@@ -41,6 +41,7 @@ export class ContainerComponent {
4141
containerTags: Signal<DockerHubTag[]> = signal([]);
4242
ontologyCategories: Signal<TermStanza[][]> = signal([]);
4343
dockerfileContent: Signal<string | undefined> = signal<string>('');
44+
relatedSoftware: Signal<RelatedSoftware | null> = signal(null);
4445

4546
selectedTab = signal<TabName>(TabName.README);
4647

@@ -89,6 +90,7 @@ export class ContainerComponent {
8990
this.containerTags = this.containerService.getContainerTagsRes(containerName).value;
9091
this.ontologyCategories = this.containerService.getContainerCategoryHierarchy(containerName);
9192
this.dockerfileContent = this.dockerfileService.getContainerDockerfileContent(containerName).value;
93+
this.relatedSoftware = this.containerService.getRelatedSoftwareRes().value;
9294
});
9395
});
9496
this.viewportScroller.setOffset([0, 150]);
@@ -237,6 +239,81 @@ export class ContainerComponent {
237239
return parentIds.flat().concat(category.id);
238240
}
239241

242+
/**
243+
* Get sorted related software by cooccurrence count and name
244+
*/
245+
getSortedRelatedSoftware(): { name: string; displayName: string; count: number; inBdip: boolean }[] {
246+
const relatedSoftwareData = this.relatedSoftware();
247+
if (!relatedSoftwareData || !this.container) {
248+
return [];
249+
}
250+
251+
const currentSoftware = this.container.name.toLowerCase();
252+
const currentEntry = relatedSoftwareData.software[currentSoftware];
253+
254+
if (!currentEntry || !currentEntry.cooccurrences) {
255+
return [];
256+
}
257+
258+
const allContainersMetadata = this.containerService.getAllContainersMetadataRes().value();
259+
const bdipContainers = new Set(allContainersMetadata ? Array.from(allContainersMetadata.keys()).map(k => k.toLowerCase()) : []);
260+
261+
return Object.entries(currentEntry.cooccurrences)
262+
.map(([softwareName, cooccurrence]) => {
263+
const softwareEntry = relatedSoftwareData.software[softwareName];
264+
const displayName = softwareEntry?.names?.[0] || softwareName;
265+
const count = cooccurrence?.count ?? 0;
266+
const inBdip = bdipContainers.has(softwareName.toLowerCase());
267+
268+
return {
269+
name: softwareName,
270+
displayName,
271+
count,
272+
inBdip
273+
};
274+
})
275+
.sort((a, b) => {
276+
// Sort by count desc
277+
if (b.count !== a.count) {
278+
return b.count - a.count;
279+
}
280+
// Then by displayName asc (case-insensitive)
281+
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
282+
});
283+
}
284+
285+
/**
286+
* Get articles where a specific software appears with the current container
287+
*/
288+
getCooccurringArticles(softwareName: string): string[] {
289+
const relatedSoftwareData = this.relatedSoftware();
290+
if (!relatedSoftwareData || !this.container) {
291+
return [];
292+
}
293+
294+
const currentSoftware = this.container.name.toLowerCase();
295+
const currentEntry = relatedSoftwareData.software[currentSoftware];
296+
297+
if (!currentEntry || !currentEntry.cooccurrences) {
298+
return [];
299+
}
300+
301+
const cooccurrence = currentEntry.cooccurrences[softwareName];
302+
303+
if (!cooccurrence || !Array.isArray(cooccurrence.articles)) {
304+
return [];
305+
}
306+
307+
return cooccurrence.articles;
308+
}
309+
310+
/**
311+
* Generate Bio-Protocol article URL from article ID
312+
*/
313+
getBioProtocolArticleUrl(articleId: string): string {
314+
return `https://bio-protocol.org/en/bpdetail?id=${articleId}&type=0`;
315+
}
316+
240317
/* Markdown reactive files */
241318
/* ---------------------------------------------------------------------------------------------------------------- */
242319
getCliMarkdown(containerMetadata: ImageMetadata): string {
@@ -375,4 +452,5 @@ enum TabName {
375452
TAGS = 'tags',
376453
TESTING = 'testing',
377454
DOCKERFILE = 'dockerfile',
455+
RELATED_SOFTWARE = 'related-software',
378456
}

src/app/models/related-software.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export type RelatedSoftware = {
2+
metadata: {
3+
generated_at: string;
4+
source_files: {
5+
extraction: string;
6+
cooccurrences: string;
7+
};
8+
cooccurrence_metadata: {
9+
generated_at: string;
10+
source_file: string;
11+
total_software: number;
12+
min_cooccurrence_threshold: number;
13+
};
14+
};
15+
articles: Article[];
16+
software: Record<string, SoftwareEntry>;
17+
}
18+
19+
export type Article = {
20+
article_number: string;
21+
title: string | null;
22+
abstract: string | null;
23+
software: ArticleSoftware[];
24+
}
25+
26+
export type ArticleSoftware = {
27+
name: string;
28+
version: string | null;
29+
url: string | null;
30+
}
31+
32+
export type SoftwareEntry = {
33+
names: string[];
34+
versions: string[];
35+
urls: string[];
36+
statistics: SoftwareStatistics;
37+
articles: string[];
38+
cooccurrences: Record<string, SoftwareCooccurrence>;
39+
}
40+
41+
export type SoftwareStatistics = {
42+
total_mentions: number;
43+
total_papers: number;
44+
cooccurrence_count: number;
45+
}
46+
47+
export type SoftwareCooccurrence = {
48+
count: number;
49+
articles: string[];
50+
}

0 commit comments

Comments
 (0)