From 6ed670acc998f83279713bc9c2a16ef7a79f66a1 Mon Sep 17 00:00:00 2001 From: Manfred Steyer Date: Tue, 8 Oct 2024 17:44:47 +0200 Subject: [PATCH 1/3] feat: filter hotspots using slider and percent --- .detective/hash | 2 +- .detective/log | 36 ++++++++- apps/backend/src/services/hotspot.ts | 81 ++++++++++++++++--- .../features/hotspot/hotspot.component.html | 18 ++--- .../app/features/hotspot/hotspot.component.ts | 2 + .../src/app/features/hotspot/hotspot.store.ts | 2 +- 6 files changed, 115 insertions(+), 26 deletions(-) diff --git a/.detective/hash b/.detective/hash index 7c5e3ee..dd87186 100644 --- a/.detective/hash +++ b/.detective/hash @@ -1 +1 @@ -979780ac55f4f8c3680055a838a645cdc8e591a0, v1.1.2 \ No newline at end of file +cc20f4a7cf2cd362d1f174f556b432a20bc7fb95, v1.1.6 \ No newline at end of file diff --git a/.detective/log b/.detective/log index 34cb500..3ef08a9 100644 --- a/.detective/log +++ b/.detective/log @@ -1,9 +1,39 @@ -"Manfred Steyer ,Sat Sep 28 23:22:37 2024 +0200 947a6f7933d93eeae42e53bbffd8d56f69486095,feat: add resizer for tree" +"Manfred Steyer ,Sun Sep 29 12:41:39 2024 +0200 4ee102065cca1a50063a871b078ec87680116efb,chore(release): publish 1.1.6" +10 0 CHANGELOG.md +1 1 apps/backend/package.json + +"Manfred Steyer ,Sun Sep 29 12:41:19 2024 +0200 0130a5533f0979c406e469c74305d8e214b4e0ee,fix: prevent word wrap in tree" +5 0 apps/frontend/src/app/shell/filter-tree/filter-tree.component.css + +"Manfred Steyer ,Sun Sep 29 12:29:48 2024 +0200 0f519048ce8d0d999ea0a9fc7a1c8953513eccbf,chore(release): publish 1.1.5" +10 0 CHANGELOG.md +1 1 apps/backend/package.json + +"Manfred Steyer ,Sun Sep 29 12:29:29 2024 +0200 527ed589150ac81ee5bc251526644cfdc6eeb11e,fix: don't wrap tree entries" +4 0 apps/frontend/src/app/shell/nav/nav.component.css + +"Manfred Steyer ,Sun Sep 29 12:12:29 2024 +0200 7a18a2d6fb97ea4d0d2bea59bd99b6b376862772,chore(release): publish 1.1.4" +10 0 CHANGELOG.md +1 1 apps/backend/package.json + +"Manfred Steyer ,Sun Sep 29 12:12:15 2024 +0200 be5836b768b8fc6d307496ace2d037cc7063c56d,fix: always show resize cursor during resizing" +2 0 apps/frontend/src/app/ui/resizer/resizer.component.ts +4 0 apps/frontend/src/styles.scss + +"Manfred Steyer ,Sun Sep 29 00:19:03 2024 +0200 567e19278e6204fd4b29ca9490043fe1fc8d7431,chore(release): publish 1.1.3" +14 0 CHANGELOG.md +1 1 apps/backend/package.json + +"Manfred Steyer ,Sat Sep 28 23:22:37 2024 +0200 7ef551cfee65bf6a037923eb6310ddf72a2a1e52,feat: add resizer for tree" 1 1 .detective/hash 16 0 .detective/log -25 0 apps/frontend/src/app/shell/nav/nav.component.css +16 2 apps/frontend/src/app/shell/nav/nav.component.css 10 6 apps/frontend/src/app/shell/nav/nav.component.html -46 8 apps/frontend/src/app/shell/nav/nav.component.ts +15 8 apps/frontend/src/app/shell/nav/nav.component.ts +10 0 apps/frontend/src/app/ui/resizer/resizer.component.css +1 0 apps/frontend/src/app/ui/resizer/resizer.component.html +22 0 apps/frontend/src/app/ui/resizer/resizer.component.spec.ts +42 0 apps/frontend/src/app/ui/resizer/resizer.component.ts "Manfred Steyer ,Sat Sep 28 19:32:42 2024 +0200 06c8dfeb5e988f17bcf3699e8d3c1748981c7231,refactor: streamline dataflow for hotspot analysis" 1 1 .detective/hash diff --git a/apps/backend/src/services/hotspot.ts b/apps/backend/src/services/hotspot.ts index 212517f..ab4a5c0 100644 --- a/apps/backend/src/services/hotspot.ts +++ b/apps/backend/src/services/hotspot.ts @@ -38,12 +38,23 @@ export type HotspotCriteria = { }; export type AggregatedHotspot = { + parent: string; module: string; count: number; + countBelow: number; }; export type AggregatedHotspotsResult = { aggregated: AggregatedHotspot[]; + minScore: number; + maxScore: number; + boundary: number; +}; + +type Stats = { + maxScore: number; + scores: Map; + minScore: number; }; export async function findHotspotFiles( @@ -80,28 +91,76 @@ export async function aggregateHotspots( limits: Limits, options: Options ): Promise { - const hotspotResult = await findHotspotFiles(criteria, limits, options); + const phase1Criteria = { + ...criteria, + minScore: 0, + }; + + const hotspotResult = await findHotspotFiles(phase1Criteria, limits, options); const hotspots = hotspotResult.hotspots; const config = loadConfig(options); const modules = config.scopes.map((m) => normalizeFolder(m)); + const stats = collectStats(modules, hotspots); + + const boundary = stats.maxScore * (criteria.minScore / 100); + const result = aggregateStats(modules, stats, boundary); + + result.sort((a, b) => b.count - a.count); + + return { + aggregated: result, + maxScore: stats.maxScore, + minScore: stats.minScore, + boundary, + }; +} + +function aggregateStats( + modules: string[], + stats: Stats, + boundary: number +): AggregatedHotspot[] { const result: AggregatedHotspot[] = []; for (const module of modules) { - let count = 0; + const moduleStats = stats.scores.get(module); + const count = moduleStats.reduce( + (acc, v) => (v > boundary ? acc + 1 : acc), + 0 + ); + const countBelow = moduleStats.length - count; + + const displayFolder = toDisplayFolder(module); + const parent = path.dirname(displayFolder); + + result.push({ + parent, + module: displayFolder, + count, + countBelow, + }); + } + return result; +} + +function collectStats(modules: string[], hotspots: FlatHotspot[]) { + let minScore = Number.MAX_VALUE; + let maxScore = 0; + const scores = new Map(); + + for (const module of modules) { + const moduleScores = []; for (const hotspot of hotspots) { - if ( - hotspot.fileName.startsWith(module) - //&& hotspot.score >= criteria.minScore - ) { - count++; + if (hotspot.fileName.startsWith(module)) { + minScore = Math.min(minScore, hotspot.score); + maxScore = Math.max(maxScore, hotspot.score); + moduleScores.push(hotspot.score); } } - result.push({ module: toDisplayFolder(module), count }); + scores.set(module, moduleScores); } - - result.sort((a, b) => b.count - a.count); - return { aggregated: result }; + return { maxScore, scores, minScore }; } async function analyzeLogs( diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.html b/apps/frontend/src/app/features/hotspot/hotspot.component.html index 7f3b147..cb7c20c 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.html +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.html @@ -1,15 +1,13 @@
- - Min. Score - - + Min. Score: (%) + + + + + {{ minScore().value() }} % Complexity Metric diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.ts b/apps/frontend/src/app/features/hotspot/hotspot.component.ts index db1e66a..ad1ffb1 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.ts +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.ts @@ -14,6 +14,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; +import { MatSliderModule } from '@angular/material/slider'; import { MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -49,6 +50,7 @@ interface Option { MatFormFieldModule, MatInputModule, MatSelectModule, + MatSliderModule, MatProgressBarModule, MatPaginatorModule, LimitsComponent, diff --git a/apps/frontend/src/app/features/hotspot/hotspot.store.ts b/apps/frontend/src/app/features/hotspot/hotspot.store.ts index bc17081..99a1576 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.store.ts +++ b/apps/frontend/src/app/features/hotspot/hotspot.store.ts @@ -35,7 +35,7 @@ export const HotspotStore = signalStore( { providedIn: 'root' }, withState({ filter: { - minScore: 1, + minScore: 33, metric: 'Length', module: '', } as HotspotFilter, From bfc6cd6788b9de0706b5d44131bb1d48a23847ed Mon Sep 17 00:00:00 2001 From: Manfred Steyer Date: Sun, 13 Oct 2024 01:53:11 +0200 Subject: [PATCH 2/3] feat: add treemap for hotspots --- .detective/config.json | 27 ++++- .detective/hash | 2 +- .detective/log | 8 ++ apps/backend/src/services/hotspot.ts | 52 ++++++-- .../app/features/hotspot/hotspot-adapter.ts | 111 ++++++++++++++++++ .../features/hotspot/hotspot.component.html | 2 + .../app/features/hotspot/hotspot.component.ts | 7 +- apps/frontend/src/app/model/hotspot-result.ts | 12 ++ .../src/app/ui/treemap/treemap.component.css | 0 .../src/app/ui/treemap/treemap.component.html | 3 + .../src/app/ui/treemap/treemap.component.ts | 71 +++++++++++ package-lock.json | 10 ++ package.json | 1 + 13 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/app/features/hotspot/hotspot-adapter.ts create mode 100644 apps/frontend/src/app/ui/treemap/treemap.component.css create mode 100644 apps/frontend/src/app/ui/treemap/treemap.component.html create mode 100644 apps/frontend/src/app/ui/treemap/treemap.component.ts diff --git a/.detective/config.json b/.detective/config.json index ea5d8b6..4c94d8e 100644 --- a/.detective/config.json +++ b/.detective/config.json @@ -4,9 +4,32 @@ "apps/backend/src/options", "apps/backend/src/services", "apps/backend/src/utils", - "apps/backend/src/infrastructure" + "apps/backend/src/infrastructure", + "apps/frontend/src/app/features/coupling", + "apps/frontend/src/app/features/hotspot", + "apps/frontend/src/app/features/team-alignment", + "apps/frontend/src/app/shell/about", + "apps/frontend/src/app/shell/filter-tree", + "apps/frontend/src/app/shell/nav", + "apps/frontend/src/app/model", + "apps/frontend/src/app/ui/doughnut", + "apps/frontend/src/app/ui/graph", + "apps/frontend/src/app/ui/limits", + "apps/frontend/src/app/ui/loading", + "apps/frontend/src/app/ui/resizer", + "apps/frontend/src/app/ui/treemap" + ], + "groups": [ + "apps/backend/src", + "apps/backend", + "apps/frontend/src/app/features", + "apps/frontend/src/app/shell", + "apps/frontend/src/app/ui", + "apps/frontend/src/app", + "apps/frontend/src", + "apps/frontend", + "apps" ], - "groups": ["apps/backend/src", "apps/backend", "apps"], "entries": [], "filter": { "files": [], diff --git a/.detective/hash b/.detective/hash index dd87186..10770bb 100644 --- a/.detective/hash +++ b/.detective/hash @@ -1 +1 @@ -cc20f4a7cf2cd362d1f174f556b432a20bc7fb95, v1.1.6 \ No newline at end of file +503c63bcf7fab325fc9687a40ad048b2b16ca636, v1.1.6 \ No newline at end of file diff --git a/.detective/log b/.detective/log index 3ef08a9..17f768c 100644 --- a/.detective/log +++ b/.detective/log @@ -1,3 +1,11 @@ +"Manfred Steyer ,Tue Oct 8 17:44:47 2024 +0200 6ed670acc998f83279713bc9c2a16ef7a79f66a1,feat: filter hotspots using slider and percent" +1 1 .detective/hash +33 3 .detective/log +70 11 apps/backend/src/services/hotspot.ts +8 10 apps/frontend/src/app/features/hotspot/hotspot.component.html +2 0 apps/frontend/src/app/features/hotspot/hotspot.component.ts +1 1 apps/frontend/src/app/features/hotspot/hotspot.store.ts + "Manfred Steyer ,Sun Sep 29 12:41:39 2024 +0200 4ee102065cca1a50063a871b078ec87680116efb,chore(release): publish 1.1.6" 10 0 CHANGELOG.md 1 1 apps/backend/package.json diff --git a/apps/backend/src/services/hotspot.ts b/apps/backend/src/services/hotspot.ts index ab4a5c0..51c1698 100644 --- a/apps/backend/src/services/hotspot.ts +++ b/apps/backend/src/services/hotspot.ts @@ -41,14 +41,17 @@ export type AggregatedHotspot = { parent: string; module: string; count: number; - countBelow: number; + countWarning: number; + countHotspot: number; + countOk: number; }; export type AggregatedHotspotsResult = { aggregated: AggregatedHotspot[]; minScore: number; maxScore: number; - boundary: number; + warningBoundary: number; + hotspotBoundary: number; }; type Stats = { @@ -104,8 +107,16 @@ export async function aggregateHotspots( const stats = collectStats(modules, hotspots); - const boundary = stats.maxScore * (criteria.minScore / 100); - const result = aggregateStats(modules, stats, boundary); + const warningBoundary = stats.maxScore * (criteria.minScore / 100); + const hotspotBoundary = + warningBoundary + (stats.maxScore - warningBoundary) / 2; + + const result = aggregateStats( + modules, + stats, + warningBoundary, + hotspotBoundary + ); result.sort((a, b) => b.count - a.count); @@ -113,23 +124,36 @@ export async function aggregateHotspots( aggregated: result, maxScore: stats.maxScore, minScore: stats.minScore, - boundary, + hotspotBoundary, + warningBoundary, }; } function aggregateStats( modules: string[], stats: Stats, - boundary: number + warningBoundary: number, + hotspotBoundary: number ): AggregatedHotspot[] { const result: AggregatedHotspot[] = []; for (const module of modules) { const moduleStats = stats.scores.get(module); - const count = moduleStats.reduce( - (acc, v) => (v > boundary ? acc + 1 : acc), - 0 - ); - const countBelow = moduleStats.length - count; + + let countWarning = 0; + let countHotspot = 0; + let countOk = 0; + + for (const stat of moduleStats) { + if (stat > hotspotBoundary) { + countHotspot++; + } else if (stat < warningBoundary) { + countOk++; + } else { + countWarning++; + } + } + + // const countBelow = moduleStats.length - count; const displayFolder = toDisplayFolder(module); const parent = path.dirname(displayFolder); @@ -137,8 +161,10 @@ function aggregateStats( result.push({ parent, module: displayFolder, - count, - countBelow, + count: countOk, + countOk, + countWarning, + countHotspot, }); } return result; diff --git a/apps/frontend/src/app/features/hotspot/hotspot-adapter.ts b/apps/frontend/src/app/features/hotspot/hotspot-adapter.ts new file mode 100644 index 0000000..7c96bb2 --- /dev/null +++ b/apps/frontend/src/app/features/hotspot/hotspot-adapter.ts @@ -0,0 +1,111 @@ +import 'chartjs-chart-treemap'; + +import { ChartEvent, InteractionItem } from 'chart.js'; + +import { AggregatedHotspot } from '../../model/hotspot-result'; +import { TreeMapChartConfig } from '../../ui/treemap/treemap.component'; + +type HotspotDataSet = { + tree: AggregatedHotspot[]; +}; + +export function toTreeMapConfig( + aggregated: AggregatedHotspot[] +): TreeMapChartConfig { + const values = aggregated.flatMap((v) => [ + { ...v, count: v.countHotspot, type: 'hotspot' }, + { ...v, count: v.countWarning, type: 'warning' }, + { ...v, count: v.countOk, type: 'fine' }, + ]); + + const options = { + onClick: (_event: ChartEvent, elements: InteractionItem[]) => { + if (elements.length > 0) { + const element = elements[elements.length - 1]; + const dataIndex = element.index; + const dataset = config.data.datasets[0] as unknown as HotspotDataSet; + const tree = dataset.tree; + const item = tree[dataIndex]; + console.log('item', item); + } + }, + onHover: (event: ChartEvent, elements: InteractionItem[]) => { + const chartElement = event.native?.target as HTMLCanvasElement; + if (elements.length >= 2) { + chartElement.style.cursor = 'pointer'; + } else { + chartElement.style.cursor = 'default'; + } + }, + plugins: { + title: { + display: true, + text: 'Hotspots', + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + title() { + return 'File Count'; + }, + }, + }, + }, + }; + + const config: TreeMapChartConfig = { + type: 'treemap', + data: { + datasets: [ + { + data: values, + key: 'count', + groups: ['parent', 'module', 'type'], + spacing: 1, + borderWidth: 0.5, + borderColor: '#EFEFEF', + // backgroundColor: 'rgba(220,230,220,0.3)', + backgroundColor: (ctx) => { + if (typeof ctx.raw?.l !== 'undefined' && ctx.raw?.l < 2) { + return '#EFEFEF'; + } + + switch (ctx.raw?.g) { + case 'hotspot': + return '#E74C3C'; + case 'warning': + return '#F1C40F'; + case 'fine': + return '#2ECC71'; + } + + return 'gray'; + }, + // hoverBackgroundColor: 'rgba(220,230,220,0.5)', + captions: { + align: 'center', + display: true, + color: 'black', + font: { + size: 14, + }, + hoverFont: { + size: 16, + weight: 'bold', + }, + padding: 5, + }, + labels: { + display: false, + overflow: 'hidden', + }, + }, + ], + }, + options: options, + }; + + return config; +} diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.html b/apps/frontend/src/app/features/hotspot/hotspot.component.html index cb7c20c..7cf8750 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.html +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.html @@ -115,3 +115,5 @@ }
+ + diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.ts b/apps/frontend/src/app/features/hotspot/hotspot.component.ts index ad1ffb1..4069cb6 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.ts +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.ts @@ -29,11 +29,13 @@ import { } from '../../model/hotspot-result'; import { Limits } from '../../model/limits'; import { LimitsComponent } from '../../ui/limits/limits.component'; +import { TreeMapComponent } from '../../ui/treemap/treemap.component'; import { debounceTimeSkipFirst } from '../../utils/debounce'; import { EventService } from '../../utils/event.service'; import { lastSegments } from '../../utils/segments'; import { mirror } from '../../utils/signal-helpers'; +import { toTreeMapConfig } from './hotspot-adapter'; import { HotspotStore } from './hotspot.store'; interface Option { @@ -57,6 +59,7 @@ interface Option { FormsModule, MatIconModule, MatTooltipModule, + TreeMapComponent, ], templateUrl: './hotspot.component.html', styleUrl: './hotspot.component.css', @@ -97,6 +100,8 @@ export class HotspotComponent { formatAggregated(this.aggregatedResult().aggregated) ); + treeMapConfig = computed(() => toTreeMapConfig(this.formattedAggregated())); + formattedHotspots = computed(() => formatHotspots( this.hotspotResult().hotspots, @@ -162,7 +167,7 @@ export class HotspotComponent { function formatAggregated(hotspot: AggregatedHotspot[]): AggregatedHotspot[] { return hotspot.map((hs) => ({ ...hs, - module: lastSegments(hs.module, 3), + module: lastSegments(hs.module, 1), })); } diff --git a/apps/frontend/src/app/model/hotspot-result.ts b/apps/frontend/src/app/model/hotspot-result.ts index 14be48d..0ae377f 100644 --- a/apps/frontend/src/app/model/hotspot-result.ts +++ b/apps/frontend/src/app/model/hotspot-result.ts @@ -30,14 +30,26 @@ export interface HotspotCriteria { } export interface AggregatedHotspot { + parent: string; module: string; count: number; + countWarning: number; + countHotspot: number; + countOk: number; } export interface AggregatedHotspotsResult { aggregated: AggregatedHotspot[]; + maxScore: number; + minScore: number; + warningBoundary: number; + hotspotBoundary: number; } export const initAggregatedHotspotsResult: AggregatedHotspotsResult = { aggregated: [], + maxScore: 0, + minScore: 0, + warningBoundary: 0, + hotspotBoundary: 0, }; diff --git a/apps/frontend/src/app/ui/treemap/treemap.component.css b/apps/frontend/src/app/ui/treemap/treemap.component.css new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/app/ui/treemap/treemap.component.html b/apps/frontend/src/app/ui/treemap/treemap.component.html new file mode 100644 index 0000000..bcacfdc --- /dev/null +++ b/apps/frontend/src/app/ui/treemap/treemap.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/apps/frontend/src/app/ui/treemap/treemap.component.ts b/apps/frontend/src/app/ui/treemap/treemap.component.ts new file mode 100644 index 0000000..73a0487 --- /dev/null +++ b/apps/frontend/src/app/ui/treemap/treemap.component.ts @@ -0,0 +1,71 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + ElementRef, + input, + OnChanges, + OnDestroy, + SimpleChanges, + viewChild, +} from '@angular/core'; +import { + CategoryScale, + Chart, + ChartConfiguration, + Legend, + LinearScale, + Title, + Tooltip, +} from 'chart.js'; +import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; + +export type TreeMapChartConfig = ChartConfiguration< + 'treemap', + object[], + string +>; + +type TreeMapChart = Chart<'treemap', object[], string>; + +Chart.register( + TreemapElement, + LinearScale, + CategoryScale, + Tooltip, + Legend, + Title, + TreemapController +); + +@Component({ + selector: 'app-treemap', + standalone: true, + imports: [CommonModule], + templateUrl: './treemap.component.html', + styleUrl: './treemap.component.css', +}) +export class TreeMapComponent implements OnChanges, OnDestroy { + canvasRef = viewChild.required>('canvas'); + chartConfig = input.required(); + + private chart: TreeMapChart | undefined; + + ngOnChanges(_changes: SimpleChanges): void { + const canvasRef = this.canvasRef(); + const canvas = canvasRef.nativeElement; + const ctx = canvas.getContext('2d'); + const config = this.chartConfig(); + + if (!ctx) { + throw new Error('2d context not found'); + } + + this.chart?.destroy(); + + this.chart = new Chart(ctx, config); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} diff --git a/package-lock.json b/package-lock.json index 37fdcbc..f14bc62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@softarc/sheriff-core": "^0.17.1", "axios": "^1.6.0", "chart.js": "^4.4.4", + "chartjs-chart-treemap": "^3.1.0", "cytoscape": "^3.30.2", "cytoscape-cola": "^2.5.1", "cytoscape-dagre": "^2.5.0", @@ -11529,6 +11530,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-chart-treemap": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/chartjs-chart-treemap/-/chartjs-chart-treemap-3.1.0.tgz", + "integrity": "sha512-0LJxj4J9sCTHmrXCFlqtoBKMJDcS7VzFeRgNBRZRwU1QSpCXJKTNk5TysPEs5/YW0XYvZoN8u44RqqLf0pAzQw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", diff --git a/package.json b/package.json index a5068cb..cf15894 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@softarc/sheriff-core": "^0.17.1", "axios": "^1.6.0", "chart.js": "^4.4.4", + "chartjs-chart-treemap": "^3.1.0", "cytoscape": "^3.30.2", "cytoscape-cola": "^2.5.1", "cytoscape-dagre": "^2.5.0", From 029289318be1b2eed67c820873597f86277ebe7f Mon Sep 17 00:00:00 2001 From: Manfred Steyer Date: Sun, 13 Oct 2024 23:19:43 +0200 Subject: [PATCH 3/3] feat: add detail view to hotspot treemap --- apps/backend/project.json | 7 +- .../app/features/hotspot/hotspot-adapter.ts | 40 ++--- .../hotspot-detail.component.css | 4 + .../hotspot-detail.component.html | 53 +++++++ .../hotspot-detail.component.ts | 87 +++++++++++ .../features/hotspot/hotspot.component.html | 86 +---------- .../app/features/hotspot/hotspot.component.ts | 139 +++++++++--------- .../src/app/features/hotspot/hotspot.store.ts | 34 ++++- apps/frontend/src/app/model/hotspot-result.ts | 2 +- .../src/app/ui/treemap/treemap.component.ts | 30 ++++ 10 files changed, 296 insertions(+), 186 deletions(-) create mode 100644 apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.css create mode 100644 apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.html create mode 100644 apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.ts diff --git a/apps/backend/project.json b/apps/backend/project.json index 8b1db5d..d339c51 100644 --- a/apps/backend/project.json +++ b/apps/backend/project.json @@ -12,7 +12,12 @@ "options": { "buildTarget": "backend:build", "runBuildTargetDependencies": false, - "args": ["--path", ".", "--open", "false"] + "args": [ + "--path", + "/Users/manfredsteyer/projects/public/standalone-example-cli", + "--open", + "false" + ] }, "configurations": { "development": { diff --git a/apps/frontend/src/app/features/hotspot/hotspot-adapter.ts b/apps/frontend/src/app/features/hotspot/hotspot-adapter.ts index 7c96bb2..122bc5e 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot-adapter.ts +++ b/apps/frontend/src/app/features/hotspot/hotspot-adapter.ts @@ -5,8 +5,9 @@ import { ChartEvent, InteractionItem } from 'chart.js'; import { AggregatedHotspot } from '../../model/hotspot-result'; import { TreeMapChartConfig } from '../../ui/treemap/treemap.component'; -type HotspotDataSet = { - tree: AggregatedHotspot[]; +export type ScoreType = 'hotspot' | 'warning' | 'fine'; +export type AggregatedHotspotWithType = AggregatedHotspot & { + type: ScoreType; }; export function toTreeMapConfig( @@ -19,16 +20,6 @@ export function toTreeMapConfig( ]); const options = { - onClick: (_event: ChartEvent, elements: InteractionItem[]) => { - if (elements.length > 0) { - const element = elements[elements.length - 1]; - const dataIndex = element.index; - const dataset = config.data.datasets[0] as unknown as HotspotDataSet; - const tree = dataset.tree; - const item = tree[dataIndex]; - console.log('item', item); - } - }, onHover: (event: ChartEvent, elements: InteractionItem[]) => { const chartElement = event.native?.target as HTMLCanvasElement; if (elements.length >= 2) { @@ -66,24 +57,12 @@ export function toTreeMapConfig( spacing: 1, borderWidth: 0.5, borderColor: '#EFEFEF', - // backgroundColor: 'rgba(220,230,220,0.3)', backgroundColor: (ctx) => { if (typeof ctx.raw?.l !== 'undefined' && ctx.raw?.l < 2) { return '#EFEFEF'; } - - switch (ctx.raw?.g) { - case 'hotspot': - return '#E74C3C'; - case 'warning': - return '#F1C40F'; - case 'fine': - return '#2ECC71'; - } - - return 'gray'; + return getScoreTypeColor(ctx.raw?.g as ScoreType); }, - // hoverBackgroundColor: 'rgba(220,230,220,0.5)', captions: { align: 'center', display: true, @@ -109,3 +88,14 @@ export function toTreeMapConfig( return config; } + +export function getScoreTypeColor(scoreType: ScoreType) { + switch (scoreType) { + case 'hotspot': + return '#E74C3C'; + case 'warning': + return '#F1C40F'; + case 'fine': + return '#2ECC71'; + } +} diff --git a/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.css b/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.css new file mode 100644 index 0000000..17bacc4 --- /dev/null +++ b/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.css @@ -0,0 +1,4 @@ +.title { + margin-left: 15px; + margin-top: 10px; +} diff --git a/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.html b/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.html new file mode 100644 index 0000000..9007618 --- /dev/null +++ b/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.html @@ -0,0 +1,53 @@ +

+
+ {{ module() }} +
+

+ +
+
+ @if (loadingHotspots()) { Determining Hotspots ... + + } @else if (hotspotResult().hotspots.length > 0) { +
+ + + + + + + + + + + + + + + + + + + + + + + +
Module{{ element.fileName }}Commits + {{ element.commits }} + + Complexity + + {{ element.complexity }} + Score + {{ element.score }} +
+
+ } +
+
+ + diff --git a/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.ts b/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.ts new file mode 100644 index 0000000..1bf1cf0 --- /dev/null +++ b/apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.ts @@ -0,0 +1,87 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + inject, + untracked, + viewChild, +} from '@angular/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatProgressBar } from '@angular/material/progress-bar'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; + +import { FlatHotspot } from '../../../model/hotspot-result'; +import { getScoreTypeColor } from '../hotspot-adapter'; +import { HotspotStore } from '../hotspot.store'; + +@Component({ + selector: 'app-hotspot-detail', + standalone: true, + imports: [ + CommonModule, + MatTableModule, + MatSortModule, + MatPaginator, + MatProgressBar, + MatDialogModule, + ], + templateUrl: './hotspot-detail.component.html', + styleUrl: './hotspot-detail.component.css', +}) +export class HotspotDetailComponent { + private hotspotStore = inject(HotspotStore); + + module = this.hotspotStore.filter.module; + color = computed(() => getScoreTypeColor(this.hotspotStore.scoreType())); + + paginator = viewChild(MatPaginator); + detailDataSource = new MatTableDataSource(); + detailColumns = ['fileName', 'commits', 'complexity', 'score']; + + loadingAggregated = this.hotspotStore.loadingAggregated; + loadingHotspots = this.hotspotStore.loadingHotspots; + + aggregatedResult = this.hotspotStore.aggregatedResult; + hotspotResult = this.hotspotStore.hotspotResult; + + formattedHotspots = computed(() => + formatHotspots( + this.hotspotResult().hotspots, + untracked(() => this.hotspotStore.filter.module()) + ) + ); + + constructor() { + effect(() => { + const hotspots = this.formattedHotspots(); + this.detailDataSource.data = hotspots; + }); + + effect(() => { + const paginator = this.paginator(); + if (paginator) { + this.detailDataSource.paginator = paginator; + } + }); + } +} + +function formatHotspots( + hotspot: FlatHotspot[], + selectedModule: string +): FlatHotspot[] { + return hotspot.map((hs) => ({ + ...hs, + fileName: trimSegments(hs.fileName, selectedModule), + })); +} + +function trimSegments(fileName: string, prefix: string): string { + if (fileName.startsWith(prefix)) { + return fileName.substring(prefix.length + 1); + } + return fileName; +} diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.html b/apps/frontend/src/app/features/hotspot/hotspot.component.html index 7cf8750..6d1fd46 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.html +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.html @@ -35,85 +35,7 @@ -
-
- @if (loadingAggregated() || (loadingAggregated() && selectedModule())) { - Determining Hotspots ... - - } @else { - - - - - - - - - - - - - -
Module{{ element.module }}Count{{ element.count }}
- } -
-
- -
-
- @if (loadingHotspots()) { Determining Hotspots ... - - } @else if (hotspotResult().hotspots.length > 0) { -
- - - - - - - - - - - - - - - - - - - - - - - -
Module{{ element.fileName }}Commits - {{ element.commits }} - - Complexity - - {{ element.complexity }} - Score - {{ element.score }} -
- -
- } -
-
- - + diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.ts b/apps/frontend/src/app/features/hotspot/hotspot.component.ts index 4069cb6..78898ec 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.ts +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.ts @@ -1,22 +1,16 @@ -import { - Component, - computed, - effect, - inject, - untracked, - viewChild, -} from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; +import { MatPaginatorModule } from '@angular/material/paginator'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; import { MatSliderModule } from '@angular/material/slider'; import { MatSortModule } from '@angular/material/sort'; -import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; import { combineLatest, startWith } from 'rxjs'; @@ -25,17 +19,24 @@ import { StatusStore } from '../../data/status.store'; import { AggregatedHotspot, ComplexityMetric, - FlatHotspot, } from '../../model/hotspot-result'; import { Limits } from '../../model/limits'; import { LimitsComponent } from '../../ui/limits/limits.component'; -import { TreeMapComponent } from '../../ui/treemap/treemap.component'; +import { + TreeMapComponent, + TreeMapEvent, +} from '../../ui/treemap/treemap.component'; import { debounceTimeSkipFirst } from '../../utils/debounce'; import { EventService } from '../../utils/event.service'; import { lastSegments } from '../../utils/segments'; import { mirror } from '../../utils/signal-helpers'; -import { toTreeMapConfig } from './hotspot-adapter'; +import { + AggregatedHotspotWithType, + ScoreType, + toTreeMapConfig, +} from './hotspot-adapter'; +import { HotspotDetailComponent } from './hotspot-detail/hotspot-detail.component'; import { HotspotStore } from './hotspot.store'; interface Option { @@ -55,6 +56,7 @@ interface Option { MatSliderModule, MatProgressBarModule, MatPaginatorModule, + MatDialogModule, LimitsComponent, FormsModule, MatIconModule, @@ -71,12 +73,9 @@ export class HotspotComponent { private eventService = inject(EventService); - paginator = viewChild(MatPaginator); - - detailDataSource = new MatTableDataSource(); + private dialog = inject(MatDialog); columnsToDisplay = ['module', 'count']; - detailColumns = ['fileName', 'commits', 'complexity', 'score']; metricOptions: Option[] = [ { id: 'Length', label: 'File Length' }, @@ -88,13 +87,9 @@ export class HotspotComponent { minScore = mirror(this.hotspotStore.filter.minScore); metric = mirror(this.hotspotStore.filter.metric); - selectedModule = mirror(this.hotspotStore.filter.module); loadingAggregated = this.hotspotStore.loadingAggregated; - loadingHotspots = this.hotspotStore.loadingHotspots; - aggregatedResult = this.hotspotStore.aggregatedResult; - hotspotResult = this.hotspotStore.hotspotResult; formattedAggregated = computed(() => formatAggregated(this.aggregatedResult().aggregated) @@ -102,13 +97,6 @@ export class HotspotComponent { treeMapConfig = computed(() => toTreeMapConfig(this.formattedAggregated())); - formattedHotspots = computed(() => - formatHotspots( - this.hotspotResult().hotspots, - untracked(() => this.selectedModule().value()) - ) - ); - constructor() { const loadAggregatedEvents = { filterChanged: this.eventService.filterChanged.pipe(startWith(null)), @@ -119,49 +107,73 @@ export class HotspotComponent { metric: toObservable(this.metric().value), }; - const loadHotspotEvent = { - ...loadAggregatedEvents, - selectedModule: toObservable(this.selectedModule().value), - }; - const loadAggregatedOptions$ = combineLatest(loadAggregatedEvents).pipe( takeUntilDestroyed() ); - const loadHotspotOptions$ = combineLatest(loadHotspotEvent).pipe( - takeUntilDestroyed() - ); - this.hotspotStore.rxLoadAggregated(loadAggregatedOptions$); - this.hotspotStore.rxLoadHotspots(loadHotspotOptions$); + } + + updateLimits(limits: Limits): void { + this.limitsStore.updateLimits(limits); + } - effect(() => { - const hotspots = this.formattedHotspots(); - this.detailDataSource.data = hotspots; + selectModule(event: TreeMapEvent): void { + const selected = event.entry as AggregatedHotspotWithType; + const selectedModule = [selected.parent, selected.module].join('/'); + const scoreRange = this.getScoreRange(selected); + + this.hotspotStore.rxLoadHotspots({ + limits: this.limits(), + metric: this.metric().value(), + selectedModule, + scoreRange, + scoreType: selected.type, }); - effect(() => { - const paginator = this.paginator(); - if (paginator) { - this.detailDataSource.paginator = paginator; - } + this.dialog.open(HotspotDetailComponent, { + width: '95%', + height: '700px', }); } - updateLimits(limits: Limits): void { - this.limitsStore.updateLimits(limits); + private getScoreRange(selected: AggregatedHotspotWithType) { + const range = this.getScoreBoundaries(); + const index = getScoreIndex(selected.type); + + const scoreRange = { + from: range[index], + to: range[index + 1], + }; + return scoreRange; } - selectRow(row: AggregatedHotspot, index: number) { - const selectedModule = this.aggregatedResult().aggregated[index].module; - this.selectedModule().value.set(selectedModule); + private getScoreBoundaries() { + const result = this.aggregatedResult(); + const range = [ + 0, + result.warningBoundary, + result.hotspotBoundary, + result.maxScore, + ]; + return range; } +} - isSelected(index: number) { - const module = this.aggregatedResult().aggregated[index].module; - const result = module === this.selectedModule().value(); - return result; +function getScoreIndex(type: ScoreType) { + let index = 0; + switch (type) { + case 'fine': + index = 0; + break; + case 'warning': + index = 1; + break; + case 'hotspot': + index = 2; + break; } + return index; } function formatAggregated(hotspot: AggregatedHotspot[]): AggregatedHotspot[] { @@ -170,20 +182,3 @@ function formatAggregated(hotspot: AggregatedHotspot[]): AggregatedHotspot[] { module: lastSegments(hs.module, 1), })); } - -function formatHotspots( - hotspot: FlatHotspot[], - selectedModule: string -): FlatHotspot[] { - return hotspot.map((hs) => ({ - ...hs, - fileName: trimSegments(hs.fileName, selectedModule), - })); -} - -function trimSegments(fileName: string, prefix: string): string { - if (fileName.startsWith(prefix)) { - return fileName.substring(prefix.length + 1); - } - return fileName; -} diff --git a/apps/frontend/src/app/features/hotspot/hotspot.store.ts b/apps/frontend/src/app/features/hotspot/hotspot.store.ts index 99a1576..7ec22b8 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.store.ts +++ b/apps/frontend/src/app/features/hotspot/hotspot.store.ts @@ -15,6 +15,8 @@ import { import { Limits } from '../../model/limits'; import { injectShowError } from '../../utils/error-handler'; +import { ScoreType } from './hotspot-adapter'; + export type HotspotFilter = { minScore: number; metric: ComplexityMetric; @@ -27,8 +29,22 @@ export type LoadAggregateOptions = { limits: Limits; }; -export type LoadHotspotOptions = LoadAggregateOptions & { +export type LoadHotspotOptions = { selectedModule: string; + scoreRange: ScoreRange; + metric: ComplexityMetric; + limits: Limits; + scoreType: ScoreType; +}; + +export type ScoreRange = { + from: number; + to: number; +}; + +const initScoreRange: ScoreRange = { + from: 0, + to: 0, }; export const HotspotStore = signalStore( @@ -38,7 +54,9 @@ export const HotspotStore = signalStore( minScore: 33, metric: 'Length', module: '', + scoreRange: initScoreRange, } as HotspotFilter, + scoreType: 'fine' as ScoreType, aggregatedResult: initAggregatedHotspotsResult, hotspotResult: initHotspotResult, loadingAggregated: false, @@ -86,14 +104,20 @@ export const HotspotStore = signalStore( _loadHotspots(options: LoadHotspotOptions): Observable { const criteria: HotspotCriteria = { metric: options.metric, - minScore: options.minScore, + minScore: 0, module: options.selectedModule, }; - patchState(store, { + patchState(store, (state) => ({ loadingHotspots: true, - filter: criteria, - }); + filter: { + ...state.filter, + module: options.selectedModule, + minScore: state.filter.minScore, + scoreRange: options.scoreRange, + }, + scoreType: options.scoreType, + })); return hotspotService.load(criteria, options.limits).pipe( tap(() => { diff --git a/apps/frontend/src/app/model/hotspot-result.ts b/apps/frontend/src/app/model/hotspot-result.ts index 0ae377f..a353d01 100644 --- a/apps/frontend/src/app/model/hotspot-result.ts +++ b/apps/frontend/src/app/model/hotspot-result.ts @@ -25,8 +25,8 @@ export type ComplexityMetric = 'McCabe' | 'Length'; export interface HotspotCriteria { module: string; - minScore: number; metric: ComplexityMetric; + minScore: number; } export interface AggregatedHotspot { diff --git a/apps/frontend/src/app/ui/treemap/treemap.component.ts b/apps/frontend/src/app/ui/treemap/treemap.component.ts index 73a0487..3bcb21a 100644 --- a/apps/frontend/src/app/ui/treemap/treemap.component.ts +++ b/apps/frontend/src/app/ui/treemap/treemap.component.ts @@ -5,6 +5,7 @@ import { input, OnChanges, OnDestroy, + output, SimpleChanges, viewChild, } from '@angular/core'; @@ -12,6 +13,8 @@ import { CategoryScale, Chart, ChartConfiguration, + ChartEvent, + InteractionItem, Legend, LinearScale, Title, @@ -27,6 +30,16 @@ export type TreeMapChartConfig = ChartConfiguration< type TreeMapChart = Chart<'treemap', object[], string>; +type Item = { + _data: { + children: unknown[]; + }; +}; + +export type TreeMapEvent = { + entry: unknown; +}; + Chart.register( TreemapElement, LinearScale, @@ -48,6 +61,8 @@ export class TreeMapComponent implements OnChanges, OnDestroy { canvasRef = viewChild.required>('canvas'); chartConfig = input.required(); + elementSelected = output(); + private chart: TreeMapChart | undefined; ngOnChanges(_changes: SimpleChanges): void { @@ -62,6 +77,21 @@ export class TreeMapComponent implements OnChanges, OnDestroy { this.chart?.destroy(); + config.options = config.options ?? {}; + config.options.onClick = ( + _event: ChartEvent, + elements: InteractionItem[] + ) => { + if (elements.length > 0) { + const element = elements[elements.length - 1]; + const dataIndex = element.index; + const dataset = config.data.datasets[0]; + const data = dataset.data; + const item = data[dataIndex] as Item; + const entry = item._data.children[0]; + this.elementSelected.emit({ entry }); + } + }; this.chart = new Chart(ctx, config); }