diff --git a/src/app/component/activity-description/activity-description.component.css b/src/app/component/activity-description/activity-description.component.css index 826aae310..3b5021443 100644 --- a/src/app/component/activity-description/activity-description.component.css +++ b/src/app/component/activity-description/activity-description.component.css @@ -448,4 +448,114 @@ mat-icon.mat-icon { .level-3 { background-color: #f57c00; color: #fff8e1; } .level-4 { background-color: #ef6c00; color: #fff3e0; } .level-5 { background-color: #c62828; color: #ffebee; } -} \ No newline at end of file +} +/* ── Activity Intelligence Panel ── */ +.intelligence-panel { + background: #f8fffe; + border: 1px solid #c8e6c9; + border-radius: 8px; + padding: 12px 16px; + margin: 10px 0 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.intel-row { + display: flex; + align-items: center; + gap: 10px; +} + +.intel-label { + font-size: 11px; + font-weight: 700; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + width: 70px; + flex-shrink: 0; +} + +.intel-useful-badge { + font-size: 11px; + font-weight: 700; + padding: 3px 10px; + border-radius: 10px; +} + +.u-high { background: #c8e6c9; color: #1b5e20; } +.u-med { background: #fff9c4; color: #f57f17; } +.u-low { background: #ffe0b2; color: #e65100; } +.u-vlow { background: #ffcdd2; color: #b71c1c; } + +.intel-diff-row { + display: flex; + flex-direction: column; + gap: 5px; +} + +.intel-bar-item { + display: flex; + align-items: center; + gap: 8px; +} + +.intel-bar-label { + font-size: 10px; + color: #888; + width: 64px; + flex-shrink: 0; +} + +.intel-bar-track { + flex: 1; + height: 6px; + background: #e8f5e9; + border-radius: 3px; + overflow: hidden; +} + +.intel-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; +} + +.bar-low { background: #66bb6a; } +.bar-med { background: #ffa726; } +.bar-high { background: #ef5350; } +.bar-vhigh { background: #b71c1c; } + +.intel-bar-val { + font-size: 10px; + color: #888; + width: 52px; + flex-shrink: 0; +} + +.intel-tools-row { + display: flex; + align-items: flex-start; + gap: 10px; + flex-wrap: wrap; +} + +.intel-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.intel-chip { + font-size: 10px; + background: #e3f2fd; + color: #1565c0; + border: 1px solid #90caf9; + border-radius: 12px; + padding: 3px 10px; + text-decoration: none; + transition: background 0.15s; +} + +.intel-chip:hover { background: #bbdefb; } diff --git a/src/app/component/activity-description/activity-description.component.html b/src/app/component/activity-description/activity-description.component.html index 28fcc4228..5ad434297 100644 --- a/src/app/component/activity-description/activity-description.component.html +++ b/src/app/component/activity-description/activity-description.component.html @@ -24,6 +24,57 @@

id: {{ currentActivity.uuid }} + +
+
+ Usefulness + + {{ currentActivity.usefulness }}/4 + +
+
+ Difficulty +
+
+ Knowledge +
+
+
+ {{ currentActivity.difficultyOfImplementation!.knowledge }}/4 +
+
+ Time +
+
+
+ {{ currentActivity.difficultyOfImplementation!.time }}/4 +
+
+ Resources +
+
+
+ {{ currentActivity.difficultyOfImplementation!.resources }}/4 +
+
+
+
+ Top Tools + +
+
+ diff --git a/src/app/component/activity-description/activity-description.component.ts b/src/app/component/activity-description/activity-description.component.ts index 2a583a7b7..364e6138c 100644 --- a/src/app/component/activity-description/activity-description.component.ts +++ b/src/app/component/activity-description/activity-description.component.ts @@ -138,6 +138,22 @@ export class ActivityDescriptionComponent implements OnInit, OnChanges { this.closeRequested.emit(); } + getUsefulnessClass(val: number): string { + const classes: Record = { 4: 'u-high', 3: 'u-med', 2: 'u-low', 1: 'u-vlow' }; + return classes[val] || 'u-med'; + } + + getDifficultyWidth(val: number): string { + return `${(val / 4) * 100}%`; + } + + getDifficultyBarClass(val: number): string { + if (val <= 1) return 'bar-low'; + if (val === 2) return 'bar-med'; + if (val === 3) return 'bar-high'; + return 'bar-vhigh'; + } + // Check if screen is narrow and update property private checkWidthForActivityPanel(): void { let elemtn: HTMLElement | null = document.querySelector('app-activity-description'); diff --git a/src/app/pages/about-us/about-us.component.css b/src/app/pages/about-us/about-us.component.css index e69de29bb..8eee7917a 100644 --- a/src/app/pages/about-us/about-us.component.css +++ b/src/app/pages/about-us/about-us.component.css @@ -0,0 +1,144 @@ +.about-page { + max-width: 960px; + padding: 24px 28px; +} + +/* Hero */ +.hero-section { + background: linear-gradient(135deg, #e8f5e9 0%, #f1f8ff 100%); + border: 1px solid #c8e6c9; + border-radius: 12px; + padding: 36px 32px; + margin-bottom: 24px; +} + +.hero-badge { + display: inline-block; + background: #2e7d32; + color: #fff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; + padding: 4px 12px; + border-radius: 10px; + margin-bottom: 12px; +} + +.hero-title { + font-size: 28px; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 10px; +} + +.hero-subtitle { + font-size: 15px; + color: #555; + line-height: 1.6; + max-width: 680px; + margin: 0 0 24px; +} + +.hero-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.hero-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: opacity 0.15s, transform 0.1s; +} + +.hero-btn:hover { opacity: 0.88; transform: translateY(-1px); } + +.hero-btn-primary { + background: #2e7d32; + color: #fff; +} + +.hero-btn-secondary { + background: #fff; + color: #2e7d32; + border: 1px solid #a5d6a7; +} + +.btn-icon { font-size: 14px; } + +/* Quick Links Grid */ +.links-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.link-card { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + text-decoration: none; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.link-card:hover { + border-color: #a5d6a7; + box-shadow: 0 2px 8px rgba(0,0,0,0.07); +} + +.link-card-icon { font-size: 24px; flex-shrink: 0; } + +.link-card-title { + font-size: 13px; + font-weight: 600; + color: #1a1a1a; + margin-bottom: 2px; +} + +.link-card-desc { + font-size: 11px; + color: #888; +} + +/* Contribute Callout */ +.contribute-callout { + display: flex; + align-items: center; + gap: 12px; + background: #fff8e1; + border: 1px solid #ffe082; + border-radius: 8px; + padding: 14px 18px; + font-size: 13px; + color: #5d4037; + margin-bottom: 28px; +} + +.callout-icon { font-size: 22px; flex-shrink: 0; } + +.contribute-callout a { + color: #2e7d32; + font-weight: 600; + text-decoration: none; +} + +.contribute-callout a:hover { text-decoration: underline; } + +/* README section */ +.readme-section { + border-top: 1px solid #e0e0e0; + padding-top: 20px; +} diff --git a/src/app/pages/about-us/about-us.component.html b/src/app/pages/about-us/about-us.component.html index b1b9b55e2..1ca7a3fdf 100644 --- a/src/app/pages/about-us/about-us.component.html +++ b/src/app/pages/about-us/about-us.component.html @@ -1,2 +1,91 @@ - + +
+ + +
+
+
OWASP Project
+

DevSecOps Maturity Model

+

+ A practical framework to harden DevOps strategies by prioritizing security measures across + your entire software delivery pipeline. +

+ +
+
+ + + + + +
+ 🤝 +
+ Want to contribute? + Create issues or pull requests on + GitHub + or join the discussion in #dsomm on + OWASP Slack. +
+
+ + +
+ +
+ +
diff --git a/src/app/pages/circular-heatmap/circular-heatmap.component.ts b/src/app/pages/circular-heatmap/circular-heatmap.component.ts index f871f4493..715e9d0ff 100644 --- a/src/app/pages/circular-heatmap/circular-heatmap.component.ts +++ b/src/app/pages/circular-heatmap/circular-heatmap.component.ts @@ -51,6 +51,7 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { filtersTeamGroups: Record = {}; teamGroups: TeamGroups = {}; hasTeamsFilter: boolean = false; + allTeamsGroupName: string = 'All'; maxLevel: number = 0; dimensionLabels: string[] = []; progressStates: string[] = []; @@ -107,10 +108,10 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { this.filtersTeams = this.buildFilters(dataStore.meta?.teams as string[]); // Insert key: 'All' with value: [], in the first position of the meta.teamGroups Record - const allTeamsGroupName: string = dataStore.getMetaString('allTeamsGroupName') || 'All'; - this.teamGroups = { [allTeamsGroupName]: [], ...(dataStore.meta?.teamGroups || {}) }; + this.allTeamsGroupName = dataStore.getMetaString('allTeamsGroupName') || 'All'; + this.teamGroups = { [this.allTeamsGroupName]: [], ...(dataStore.meta?.teamGroups || {}) }; this.filtersTeamGroups = this.buildFilters(Object.keys(this.teamGroups)); - this.filtersTeamGroups[allTeamsGroupName] = true; + this.filtersTeamGroups[this.allTeamsGroupName] = true; let progressDefinition: ProgressDefinitions = dataStore.meta?.progressDefinition || {}; this.sectorService.init( @@ -210,9 +211,12 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { toggleTeamGroupFilter(chip: MatChip) { let teamGroup = chip.value.trim(); - if (!chip.selected) { - chip.toggleSelected(); - console.log(`${perfNow()}: Heat: Chip flip Group '${teamGroup}: ${chip.selected}`); + if (!this.filtersTeamGroups[teamGroup]) { + // Select this group, deselect all others + Object.keys(this.filtersTeamGroups).forEach(key => (this.filtersTeamGroups[key] = false)); + this.filtersTeamGroups[teamGroup] = true; + this.filtersTeamGroups = { ...this.filtersTeamGroups }; + console.log(`${perfNow()}: Heat: Chip flip Group '${teamGroup}: selected`); // Update the team selections based on the team group selection let selectedTeams: TeamName[] = []; @@ -221,12 +225,20 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { if (this.filtersTeams[key]) { selectedTeams.push(key); } - this.sectorService.setVisibleTeams(selectedTeams); }); - this.hasTeamsFilter = Object.values(this.filtersTeams).some(v => v === true); + this.sectorService.setVisibleTeams(selectedTeams); + this.hasTeamsFilter = selectedTeams.length > 0; this.reColorHeatmap(); } else { - console.log(`${perfNow()}: Heat: Chip flip Group '${teamGroup}: already on`); + // Deselect this group, go back to All + Object.keys(this.filtersTeams).forEach(key => (this.filtersTeams[key] = false)); + this.hasTeamsFilter = false; + this.sectorService.setVisibleTeams([]); + Object.keys(this.filtersTeamGroups).forEach(key => (this.filtersTeamGroups[key] = false)); + this.filtersTeamGroups[this.allTeamsGroupName] = true; + this.filtersTeamGroups = { ...this.filtersTeamGroups }; + console.log(`${perfNow()}: Heat: Chip flip Group '${teamGroup}: deselected, back to All`); + this.reColorHeatmap(); } } @@ -247,7 +259,7 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { let match: boolean = equalArray(selectedTeams, this.teamGroups[group]); this.filtersTeamGroups[group] = match; }); - this.filtersTeamGroups = this.filtersTeamGroups; + this.filtersTeamGroups = { ...this.filtersTeamGroups }; this.reColorHeatmap(); } diff --git a/src/app/pages/mapping/mapping.component.ts b/src/app/pages/mapping/mapping.component.ts index 3bc3107bb..b0a60698c 100644 --- a/src/app/pages/mapping/mapping.component.ts +++ b/src/app/pages/mapping/mapping.component.ts @@ -106,7 +106,10 @@ export class MappingComponent implements OnInit, AfterViewInit { this.dataSource.sortingDataAccessor = (item: MappingRow, property: string) => { const value = (item as any)[property]; if (Array.isArray(value)) { - return value.join(', '); + const joined = value.join(', '); + return (property === 'ISO17' || property === 'ISO22') + ? joined.replace(/\d+/g, n => n.padStart(3, '0')) + : joined; } return value; }; diff --git a/src/app/pages/matrix/matrix.component.css b/src/app/pages/matrix/matrix.component.css index 33061168f..44401dabe 100644 --- a/src/app/pages/matrix/matrix.component.css +++ b/src/app/pages/matrix/matrix.component.css @@ -140,3 +140,24 @@ color: rgba(0, 0, 0, 0.54); padding: 24px; } + +/* ── Activity Intelligence Toolbar ── */ +.intelligence-toolbar { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin: 8px 0 4px; + padding: 10px 14px; + background: #f8fffe; + border: 1px solid #c8e6c9; + border-radius: 8px; +} + +.tool-filter-field { + width: 260px; +} + +.sort-field { + width: 220px; +} diff --git a/src/app/pages/matrix/matrix.component.html b/src/app/pages/matrix/matrix.component.html index 320f22ae9..6cba119f1 100644 --- a/src/app/pages/matrix/matrix.component.html +++ b/src/app/pages/matrix/matrix.component.html @@ -27,6 +27,22 @@ + +
+ + Filter by Tool + + + + Sort by + + Default + Effort: Low to High + Effort: High to Low + + +
+ diff --git a/src/app/pages/matrix/matrix.component.ts b/src/app/pages/matrix/matrix.component.ts index 029623039..c33e9ffd3 100644 --- a/src/app/pages/matrix/matrix.component.ts +++ b/src/app/pages/matrix/matrix.component.ts @@ -38,6 +38,8 @@ export class MatrixComponent implements OnInit { levels: Partial> = {}; filtersTag: Record = {}; filtersDim: Record = {}; + toolFilter: string = ''; + sortMode: string = 'default'; columnNames: string[] = []; allCategoryNames: string[] = []; allDimensionNames: string[] = []; @@ -172,8 +174,9 @@ export class MatrixComponent implements OnInit { updateActivitiesBeingDisplayed(): void { let hasDimFilter = Object.values(this.filtersDim).some(v => v === true); let hasTagFilter = Object.values(this.filtersTag).some(v => v === true); - - if (!hasTagFilter && !hasDimFilter) { + const hasToolFilter = this.toolFilter.trim().length > 0; + const hasSort = this.sortMode !== 'default'; + if (!hasTagFilter && !hasDimFilter && !hasToolFilter && !hasSort) { this.dataSource.data = this.MATRIX_DATA; return; } @@ -224,7 +227,55 @@ export class MatrixComponent implements OnInit { } } } - this.dataSource.data = itemsStage2; + // Apply tool filter + let itemsStage3: MatrixRow[]; + const toolQ = this.toolFilter.trim().toLowerCase(); + if (!toolQ) { + itemsStage3 = itemsStage2; + } else { + itemsStage3 = []; + for (const srcItem of itemsStage2) { + const filtered: Partial = { Category: srcItem.Category, Dimension: srcItem.Dimension }; + let hasContent = false; + for (const lvl of Object.keys(this.levels) as LevelKey[]) { + const matching = (srcItem[lvl] || []).filter(a => + a.implementation?.some((t: any) => t.name?.toLowerCase().includes(toolQ)) + ); + if (matching.length) { filtered[lvl] = matching; hasContent = true; } + } + if (hasContent) itemsStage3.push(filtered as MatrixRow); + } + } + + // Apply sort within each level column + if (this.sortMode !== 'default') { + const asc = this.sortMode === 'effort-asc'; + for (const row of itemsStage3) { + for (const lvl of Object.keys(this.levels) as LevelKey[]) { + if (row[lvl]) { + row[lvl] = [...row[lvl]].sort((a, b) => + asc ? this.effortScore(a) - this.effortScore(b) : this.effortScore(b) - this.effortScore(a) + ); + } + } + } + } + + this.dataSource.data = itemsStage3; + } + + applyToolFilter() { + this.updateActivitiesBeingDisplayed(); + } + + applySortMode() { + this.updateActivitiesBeingDisplayed(); + } + + effortScore(activity: Activity): number { + const d = activity.difficultyOfImplementation; + if (!d) return 0; + return (d.knowledge || 0) + (d.time || 0) + (d.resources || 0); } hasTag(activity: Activity): boolean { diff --git a/src/app/pages/roadmap/roadmap.component.css b/src/app/pages/roadmap/roadmap.component.css index e69de29bb..17ec3bf3f 100644 --- a/src/app/pages/roadmap/roadmap.component.css +++ b/src/app/pages/roadmap/roadmap.component.css @@ -0,0 +1,171 @@ +.roadmap-page { + padding: 24px 28px; + max-width: 960px; +} + +.roadmap-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.roadmap-title { + font-size: 22px; + font-weight: 600; + color: #1a1a1a; +} + +.toolbar-right { display: flex; gap: 8px; } + +.view-btn { + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + padding: 6px 14px; + font-size: 12px; + cursor: pointer; + color: #555; +} + +.view-btn.active { + background: #e8f5e9; + border-color: #a5d6a7; + color: #2e7d32; + font-weight: 600; +} + +.legend { display: flex; gap: 16px; margin-bottom: 20px; font-size: 12px; } +.legend-item { display: flex; align-items: center; gap: 4px; color: #555; } +.done-dot { color: #2e7d32; font-weight: 600; } +.avail-dot { color: #1565c0; font-weight: 600; } +.lock-dot { color: #aaa; } + +.loading-msg { color: #888; font-size: 14px; padding: 20px; } + +.level-section { margin-bottom: 28px; } + +.level-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } + +.level-badge { + background: #2e7d32; + color: #fff; + font-size: 11px; + font-weight: 700; + padding: 3px 10px; + border-radius: 10px; + letter-spacing: 0.5px; +} + +.level-count { font-size: 11px; color: #888; } + +.timeline-track { + position: relative; + padding-left: 28px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.timeline-line { + position: absolute; + left: 7px; + top: 0; + bottom: 0; + width: 2px; + background: #c8e6c9; + border-radius: 1px; +} + +.card-wrapper { position: relative; display: flex; align-items: flex-start; } + +.timeline-dot { + position: absolute; + left: -21px; + top: 14px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid #fff; + flex-shrink: 0; +} + +.dot-done { background: #2e7d32; box-shadow: 0 0 0 2px #2e7d32; } +.dot-avail { background: #1565c0; box-shadow: 0 0 0 2px #1565c0; } +.dot-locked { background: #bbb; box-shadow: 0 0 0 2px #bbb; } + +.activity-card { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 12px 14px; + width: 100%; +} + +.card-done { border-color: #a5d6a7; background: #f1f8f1; } +.card-available { border-color: #90caf9; box-shadow: 0 0 0 2px #e3f2fd; } +.card-locked { background: #fafafa; opacity: 0.8; } + +.card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.card-name { font-size: 13px; font-weight: 600; color: #1a1a1a; flex: 1; } +.card-locked .card-name { color: #888; } + +.card-badges { display: flex; gap: 6px; flex-wrap: wrap; flex-shrink: 0; } + +.state-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; } +.state-done { background: #c8e6c9; color: #1b5e20; } +.state-available { background: #bbdefb; color: #0d47a1; } +.state-locked { background: #f5f5f5; color: #999; border: 1px solid #e0e0e0; } + +.useful-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; } +.u-high { background: #c8e6c9; color: #1b5e20; } +.u-med { background: #fff9c4; color: #f57f17; } +.u-low { background: #ffe0b2; color: #e65100; } +.u-vlow { background: #ffcdd2; color: #b71c1c; } + +.diff-section { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; } + +.diff-row { display: flex; align-items: center; gap: 8px; } + +.diff-label { font-size: 10px; color: #888; width: 64px; flex-shrink: 0; } + +.bar-track { flex: 1; height: 5px; background: #f0f0f0; border-radius: 3px; overflow: hidden; } + +.bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } +.bar-low { background: #66bb6a; } +.bar-med { background: #ffa726; } +.bar-high { background: #ef5350; } +.bar-vhigh { background: #b71c1c; } + +.diff-val { font-size: 10px; color: #888; width: 14px; text-align: right; flex-shrink: 0; } + +.tools-section { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; } + +.tool-chip { + font-size: 10px; + background: #e3f2fd; + color: #1565c0; + border: 1px solid #90caf9; + border-radius: 12px; + padding: 2px 9px; + text-decoration: none; +} + +.tool-chip:hover { background: #bbdefb; } + +.locked-msg { font-size: 10px; color: #999; margin-top: 4px; font-style: italic; } + +.dag-view { + height: 500px; + border: 1px solid #e0e0e0; + border-radius: 8px; + overflow: hidden; +} diff --git a/src/app/pages/roadmap/roadmap.component.html b/src/app/pages/roadmap/roadmap.component.html index f1c7b6f88..938046523 100644 --- a/src/app/pages/roadmap/roadmap.component.html +++ b/src/app/pages/roadmap/roadmap.component.html @@ -1,4 +1,111 @@ - -
- + + +
+ +
+
Activity Roadmap
+
+ + +
+
+ +
+ ✓ Done + ▶ Available + 🔒 Locked +
+ +
Loading activities...
+ +
+
+ +
+ Level {{ lvl }} + {{ levelGroups.get(lvl)?.length }} activities +
+ +
+
+ +
+
+
+ +
+ +
+
{{ card.activity.name }}
+
+ + ✓ Done + Available + 🔒 Locked + + + Useful: {{ card.activity.usefulness }}/4 + +
+
+ +
+
+ Knowledge +
+
+
+ {{ card.activity.difficultyOfImplementation.knowledge }} +
+
+ Time +
+
+
+ {{ card.activity.difficultyOfImplementation.time }} +
+
+ Resources +
+
+
+ {{ card.activity.difficultyOfImplementation.resources }} +
+
+ + + +
+ Needs: {{ card.missingDeps.slice(0, 2).join(', ') }}{{ card.missingDeps.length > 2 ? '...' : '' }} +
+ +
+
+ +
+
+
+ +
+ +
+
diff --git a/src/app/pages/roadmap/roadmap.component.ts b/src/app/pages/roadmap/roadmap.component.ts index c2966f9d3..0dc0ddab9 100644 --- a/src/app/pages/roadmap/roadmap.component.ts +++ b/src/app/pages/roadmap/roadmap.component.ts @@ -1,15 +1,83 @@ import { Component, OnInit } from '@angular/core'; +import { LoaderService } from 'src/app/service/loader/data-loader.service'; +import { Activity } from 'src/app/model/activity-store'; +import { DataStore } from 'src/app/model/data-store'; import { perfNow } from 'src/app/util/util'; +export type ActivityState = 'done' | 'available' | 'locked'; + +export interface RoadmapCard { + activity: Activity; + state: ActivityState; + missingDeps: string[]; +} + @Component({ selector: 'app-roadmap', templateUrl: './roadmap.component.html', styleUrls: ['./roadmap.component.css'], }) export class RoadmapComponent implements OnInit { - constructor() {} + levelGroups: Map = new Map(); + levels: number[] = []; + loading = true; + viewMode: 'timeline' | 'dag' = 'timeline'; + + constructor(private loader: LoaderService) {} ngOnInit() { console.log(`${perfNow()}: Page loaded: Roadmap`); + this.loader.load().then((dataStore: DataStore) => { + if (!dataStore.activityStore) return; + const activities = dataStore.activityStore.getAllActivities(); + this.buildTimeline(activities); + this.loading = false; + }); + } + + buildTimeline(activities: Activity[]) { + const doneNames = new Set( + activities.filter(a => a.isImplemented).map(a => a.name) + ); + + const cards: RoadmapCard[] = activities.map(a => { + const deps = a.dependsOn || []; + const missingDeps = deps.filter(d => !doneNames.has(d)); + let state: ActivityState; + if (a.isImplemented) { + state = 'done'; + } else if (missingDeps.length === 0) { + state = 'available'; + } else { + state = 'locked'; + } + return { activity: a, state, missingDeps }; + }); + + const groups = new Map(); + for (const card of cards) { + const lvl = card.activity.level || 1; + if (!groups.has(lvl)) groups.set(lvl, []); + groups.get(lvl)!.push(card); + } + + this.levelGroups = groups; + this.levels = Array.from(groups.keys()).sort((a, b) => a - b); + } + + getUsefulnessClass(val: number): string { + const classes: Record = { 4: 'u-high', 3: 'u-med', 2: 'u-low', 1: 'u-vlow' }; + return classes[val] || 'u-med'; + } + + getDifficultyWidth(val: number): string { + return `${(val / 4) * 100}%`; + } + + getDifficultyBarClass(val: number): string { + if (val <= 1) return 'bar-low'; + if (val === 2) return 'bar-med'; + if (val === 3) return 'bar-high'; + return 'bar-vhigh'; } }