Decision Matrix Table
blockproductivityProduct and architectural decision matrix with dynamic weighted scoring algorithms (Impact, Confidence, Effort, Risk), RICE framework toggle, interactive weight sliders, option creator, multi-dimensional SVG tradeoff radar, and ADR consensus breakdown.
Installation
$pnpm dlx uipkge-ng@latest add decision-matrix-table$npx uipkge-ng@latest add decision-matrix-table$yarn dlx uipkge-ng@latest add decision-matrix-table$bunx uipkge-ng@latest add decision-matrix-tableManifest:
https://uipkge.dev/r/angular/decision-matrix-table.jsonInstalls to:src/app/components/blocks/decision-matrix-table/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
DecisionCriterionDefinterface DecisionCriterionDef {
key: 'impact' | 'confidence' | 'effort' | 'dx' | 'maintenance'
label: string
shortLabel: string
description: string
defaultWeight: number
}DecisionMatrixOptioninterface DecisionMatrixOption {
id: string
name: string
architecture: string
summary: string
keyRisk: string
color: string
pros: string[]
cons: string[]
scores: { impact: number; confidence: number; effort: number; dx: number; maintenance: number }
rice: { reach: number; impact: number; confidence: number; effort: number }
isCustom?: boolean
}Files installed (2)
src/app/components/blocks/decision-matrix-table/decision-matrix-table.component.ts47.6 kB
// Architectural decision matrix with weighted scoring (Impact, Confidence, Effort, DX, // Maintenance), a RICE framework toggle, interactive weight sliders, an option creator, // a multi-dimensional SVG tradeoff radar, and an ADR consensus breakdown. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { AlertCircle, Award, BarChart3, Check, CheckCircle2, ChevronDown, ChevronUp, FileCode2, HelpCircle, Info, LucideAngularModule, Plus, PlusCircle, RefreshCw, Scale, ShieldCheck, SlidersHorizontal, Trash2, XCircle, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiSliderComponent } from '@/ui/slider/slider.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' export type DecisionFrameworkType = 'weighted-matrix' | 'rice' export interface DecisionCriterionDef { key: 'impact' | 'confidence' | 'effort' | 'dx' | 'maintenance' label: string shortLabel: string description: string defaultWeight: number } export interface DecisionMatrixOption { id: string name: string architecture: string summary: string keyRisk: string color: string pros: string[] cons: string[] scores: { impact: number; confidence: number; effort: number; dx: number; maintenance: number } rice: { reach: number; impact: number; confidence: number; effort: number } isCustom?: boolean } export interface ScoredDecisionOption extends DecisionMatrixOption { weightedScore: number riceScore: number rank: number verdict: 'Recommended Winner' | 'Second Choice' | 'Alternative' | 'Discarded' verdictVariant: 'success' | 'info' | 'warning' | 'destructive' } const criteriaList: DecisionCriterionDef[] = [ { key: 'impact', label: 'Engineering Impact', shortLabel: 'Impact', description: 'System modularity, bundle footprint reduction, and runtime performance gains.', defaultWeight: 25, }, { key: 'confidence', label: 'Confidence / Feasibility', shortLabel: 'Confidence', description: 'Ecosystem support, library maturity, and developer team readiness.', defaultWeight: 25, }, { key: 'effort', label: 'Simplicity (Low Effort)', shortLabel: 'Low Effort', description: 'Frictionless adoption curve, zero monolith refactors, fast time to market.', defaultWeight: 15, }, { key: 'dx', label: 'DX & Consumer Ownership', shortLabel: 'DX Ownership', description: 'Full source access, effortless Tailwind styling customization, zero semver lock-in.', defaultWeight: 20, }, { key: 'maintenance', label: 'Low Maintenance', shortLabel: 'Low Maint.', description: 'Decoupled component lifecycles, zero breaking upstream transitive dependency cascades.', defaultWeight: 15, }, ] const initialOptions: DecisionMatrixOption[] = [ { id: 'opt-a', name: 'Option A: Unbundled Registry architecture', architecture: 'UIPKGE / shadcn Model', summary: 'Components copy directly into consumer source tree. Zero semver locks, complete styling autonomy, and unconstrained design customization.', keyRisk: 'Downstream drift from upstream patches managed through automated registry diffing.', color: '#10b981', pros: [ 'Zero npm package version lock-in or dependency gridlock', 'Immediate Tailwind class-level and DOM customization', '100% dead-code elimination and optimal bundle footprint', ], cons: ['Manual or CLI-assisted updates when upstream security patches land'], scores: { impact: 4.8, confidence: 4.7, effort: 4.5, dx: 5.0, maintenance: 4.4 }, rice: { reach: 96, impact: 5.0, confidence: 95, effort: 1.8 }, }, { id: 'opt-b', name: 'Option B: Monolithic Multi-Framework NPM', architecture: 'Centralized Monorepo Package', summary: 'Single heavy versioned library published to private registry. Standardized tokens but painful breaking version cascades across product suites.', keyRisk: 'High cross-team dependency coupling and blocked semver upgrades.', color: '#f59e0b', pros: [ 'Single point of governance for security and accessibility patches', 'Familiar `npm install` workflow for junior developers', ], cons: [ 'Massive wrapper overhead and difficult Tailwind class overrides', 'Breaking changes in one component delay releases for entire company', 'Bloated runtime JS bundle containing unused component code', ], scores: { impact: 3.2, confidence: 4.1, effort: 3.8, dx: 2.3, maintenance: 2.6 }, rice: { reach: 82, impact: 3.0, confidence: 85, effort: 3.6 }, }, { id: 'opt-c', name: 'Option C: Custom Web Components Wrapper', architecture: 'Custom Elements v1 & Shadow DOM', summary: 'Framework-agnostic web components encapsulated in Shadow DOM. Cross-platform runtime interoperability, but major SSR hydration and styling friction.', keyRisk: 'SSR rendering latency and challenging CSS theme token propagation through shadow boundaries.', color: '#6366f1', pros: [ 'Universal encapsulation across Vue, React, Angular, and vanilla HTML', 'Strict CSS isolation prevents accidental style collisions', ], cons: [ 'Complex SSR and Declarative Shadow DOM hydration mechanics', 'Difficult theme token synchronization and slot styling gymnastics', 'Suboptimal ergonomics with React synthetic event systems', ], scores: { impact: 3.7, confidence: 3.4, effort: 2.8, dx: 3.1, maintenance: 3.5 }, rice: { reach: 90, impact: 3.5, confidence: 70, effort: 3.2 }, }, { id: 'opt-d', name: 'Option D: Micro-Frontend Module Federation', architecture: 'Webpack / Vite Runtime Federation', summary: 'Dynamic runtime module stitching over HTTP. Independent team deployments, but vulnerable to CSS leakage, network latency, and orchestration failures.', keyRisk: 'Runtime script evaluation latency and version mismatch instability in production.', color: '#ef4444', pros: ['Fully decentralized team deployments without monorepo synchronization'], cons: [ 'High network overhead and cumulative layout shifts on initial load', 'Complex local development sandbox and shared singleton debugging', 'Runtime failures when remote hosts experience temporary downtime', ], scores: { impact: 3.0, confidence: 2.5, effort: 1.8, dx: 2.6, maintenance: 1.6 }, rice: { reach: 58, impact: 3.0, confidence: 55, effort: 4.6 }, }, ] const radarCenter = { x: 170, y: 155 } const radarRadius = 95 const radarAxesCount = 5 const radarAxisLabels = [ { index: 0, label: 'Impact', x: 170, y: 38, anchor: 'middle' }, { index: 1, label: 'Confidence', x: 285, y: 115, anchor: 'start' }, { index: 2, label: 'Simplicity', x: 245, y: 258, anchor: 'middle' }, { index: 3, label: 'DX & Own', x: 95, y: 258, anchor: 'middle' }, { index: 4, label: 'Low Maint', x: 55, y: 115, anchor: 'end' }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-decision-matrix-table, [ui-decision-matrix-table]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiSeparatorComponent, UiSliderComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], template: ` <div data-slot="decision-matrix-table" [class]="rootClass"> <!-- Header Card: Controls and Metadata --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-4"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="space-y-1.5"> <div class="flex flex-wrap items-center gap-2"> <span ui-badge variant="outline" class="gap-1 text-xs font-semibold tracking-wide uppercase"> <lucide-icon [img]="Scale" class="text-primary size-3.5" /> ADR-042 Evaluation </span> <span ui-badge variant="secondary" class="text-xs">Updated Aug 2026</span> </div> <h3 ui-card-title class="text-xl font-semibold tracking-tight sm:text-2xl">{{ title }}</h3> <p ui-card-description class="text-sm">{{ subtitle }}</p> </div> <!-- Framework Switcher & Action Toolbar --> <div class="flex flex-wrap items-center gap-2.5"> <div class="bg-muted border-border inline-flex items-center rounded-lg border p-1"> <button type="button" [class]="frameworkButtonClass('weighted-matrix')" (click)="activeFramework.set('weighted-matrix')" > Weighted Value-Risk Matrix </button> <button type="button" [class]="frameworkButtonClass('rice')" (click)="activeFramework.set('rice')"> RICE Framework </button> </div> @if (activeFramework() === 'weighted-matrix') { <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs" (click)="showWeightEditor.set(!showWeightEditor())" > <lucide-icon [img]="SlidersHorizontal" class="text-muted-foreground size-3.5" /> <span>Criterion Weights</span> <span ui-badge variant="secondary" [class]="weightBadgeClass">{{ totalWeight() }}%</span> @if (!showWeightEditor()) { <lucide-icon [img]="ChevronDown" class="text-muted-foreground size-3" /> } @else { <lucide-icon [img]="ChevronUp" class="text-muted-foreground size-3" /> } </button> } <button ui-button size="sm" class="gap-1.5 text-xs font-medium" (click)="showAddOptionForm.set(!showAddOptionForm())" > <lucide-icon [img]="Plus" class="size-3.5" /> <span>Add Option</span> </button> </div> </div> </div> <!-- Expandable Criterion Weights Adjuster --> @if (showWeightEditor() && activeFramework() === 'weighted-matrix') { <div class="border-border bg-muted/20 border-t px-6 py-5"> <div class="space-y-4"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div> <h4 class="text-foreground text-sm font-semibold tracking-tight"> Calibrate Evaluation Criteria Weights </h4> <p class="text-muted-foreground text-xs"> Adjust criteria percentages to reflect your organization's architectural priorities. </p> </div> <div class="flex items-center gap-2"> <span [class]="totalWeightClass"> @if (totalWeight() === 100) { <lucide-icon [img]="CheckCircle2" class="size-3" /> } @else { <lucide-icon [img]="AlertCircle" class="size-3" /> } Total Weight: {{ totalWeight() }}% </span> <button ui-button variant="ghost" size="xs" class="text-muted-foreground gap-1 text-xs" (click)="resetWeights()" > <lucide-icon [img]="RefreshCw" class="size-3" /> Reset Defaults </button> </div> </div> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"> @for (criterion of criteria; track criterion.key) { <div class="border-border bg-card space-y-2.5 rounded-lg border p-3.5 shadow-2xs"> <div class="flex items-center justify-between gap-1"> <span class="text-foreground truncate text-xs font-semibold" [attr.title]="criterion.label"> {{ criterion.label }} </span> <span class="bg-primary/10 text-primary rounded px-1.5 py-0.5 text-xs font-bold tabular-nums"> {{ criterionWeights()[criterion.key] ?? criterion.defaultWeight }}% </span> </div> <p class="text-muted-foreground line-clamp-2 h-8 text-xs leading-tight"> {{ criterion.description }} </p> <div class="pt-1"> <ui-slider [value]="[criterionWeights()[criterion.key] ?? criterion.defaultWeight]" [min]="0" [max]="50" [step]="5" (valueChange)="setWeight(criterion.key, $event)" /> </div> </div> } </div> </div> </div> } <!-- Expandable Add Custom Proposal Form --> @if (showAddOptionForm()) { <div class="border-border bg-muted/30 border-t px-6 py-5"> <div class="max-w-3xl space-y-4"> <div class="flex items-center justify-between gap-x-2"> <h4 class="text-foreground flex items-center gap-1.5 text-sm font-semibold tracking-tight"> <lucide-icon [img]="PlusCircle" class="text-primary size-4" /> Add Technical Architecture Option to Matrix </h4> <button ui-button variant="ghost" size="xs" (click)="showAddOptionForm.set(false)">Cancel</button> </div> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="space-y-1"> <label class="text-foreground text-xs font-medium">Option Proposal Name</label> <ui-input [value]="newOptionName()" (valueChange)="newOptionName.set($event)" placeholder="e.g. Option E: Shared UI Submodule" /> </div> <div class="space-y-1"> <label class="text-foreground text-xs font-medium">Architectural architecture</label> <ui-input [value]="newOptionParadigm()" (valueChange)="newOptionParadigm.set($event)" placeholder="e.g. Git Submodule & Monorepo Link" /> </div> </div> <div class="space-y-1"> <label class="text-foreground text-xs font-medium">Executive Architectural Summary</label> <ui-input [value]="newOptionSummary()" (valueChange)="newOptionSummary.set($event)" placeholder="Brief summary of distribution mechanics, bundle characteristics, and DX impact..." /> </div> <div class="space-y-2"> <label class="text-foreground text-xs font-medium">Initial Scores (1.0 to 5.0)</label> <div class="grid grid-cols-2 gap-2.5 sm:grid-cols-5"> @for (criterion of criteria; track criterion.key) { <div class="border-border bg-card rounded-md border p-2 text-center"> <div class="text-muted-foreground truncate text-xs font-medium">{{ criterion.shortLabel }}</div> <div class="text-foreground mt-0.5 text-sm font-bold tabular-nums"> {{ newOptionScores()[criterion.key] }} / 5 </div> <div class="pt-1.5"> <ui-slider [value]="[newOptionScores()[criterion.key]]" [min]="1" [max]="5" [step]="0.5" (valueChange)="setNewScore(criterion.key, $event)" /> </div> </div> } </div> </div> <div class="flex items-center justify-end gap-2 pt-2"> <button ui-button variant="outline" size="sm" (click)="showAddOptionForm.set(false)">Cancel</button> <button ui-button size="sm" [disabled]="!newOptionName().trim()" (click)="handleAddOption()"> Add to Matrix </button> </div> </div> </div> } </div> <!-- Main Scoring Matrix Table --> <div ui-card class="border-border bg-card overflow-hidden shadow-xs"> <div class="overflow-x-auto"> <table ui-table> <thead ui-table-header class="bg-muted/50"> <tr ui-table-row class="hover:bg-transparent"> <th ui-table-head class="text-muted-foreground w-[42px] px-3 text-center text-xs font-semibold"> Rank </th> <th ui-table-head class="text-foreground min-w-[240px] text-xs font-semibold"> Technical Option & architecture </th> @if (activeFramework() === 'weighted-matrix') { @for (criterion of criteria; track criterion.key) { <th ui-table-head class="text-foreground min-w-[130px] text-center text-xs font-semibold"> <div class="flex flex-col items-center"> <span>{{ criterion.shortLabel }}</span> <span class="text-muted-foreground text-xs font-normal tabular-nums"> {{ criterionWeights()[criterion.key] ?? criterion.defaultWeight }}% wt </span> </div> </th> } <th ui-table-head class="text-foreground min-w-[140px] text-center text-xs font-semibold"> Weighted Score </th> } @else { <th ui-table-head class="text-foreground min-w-[110px] text-center text-xs font-semibold"> <div class="flex flex-col items-center"> <span>Reach</span><span class="text-muted-foreground text-xs font-normal">Dev Scale %</span> </div> </th> <th ui-table-head class="text-foreground min-w-[110px] text-center text-xs font-semibold"> <div class="flex flex-col items-center"> <span>Impact</span><span class="text-muted-foreground text-xs font-normal">Multiplier (1-5)</span> </div> </th> <th ui-table-head class="text-foreground min-w-[110px] text-center text-xs font-semibold"> <div class="flex flex-col items-center"> <span>Confidence</span><span class="text-muted-foreground text-xs font-normal">% Certainty</span> </div> </th> <th ui-table-head class="text-foreground min-w-[110px] text-center text-xs font-semibold"> <div class="flex flex-col items-center"> <span>Effort</span><span class="text-muted-foreground text-xs font-normal">Sprints (1-5)</span> </div> </th> <th ui-table-head class="text-foreground min-w-[130px] text-center text-xs font-semibold"> RICE Score </th> } <th ui-table-head class="text-foreground min-w-[160px] text-center text-xs font-semibold"> ADR Verdict </th> <th ui-table-head class="text-muted-foreground w-[80px] text-center text-xs font-semibold">Inspect</th> </tr> </thead> <tbody ui-table-body> @for (opt of scoredOptions(); track opt.id) { <tr ui-table-row [class]="matrixRowClass(opt)" (click)="selectedOptionId.set(opt.id)"> <td ui-table-cell class="px-3 text-center"> <span [class]="rankClass(opt.rank)">{{ opt.rank }}</span> </td> <td ui-table-cell> <div class="space-y-1 py-1"> <div class="flex flex-wrap items-center gap-2"> <span class="text-foreground text-sm font-semibold tracking-tight">{{ opt.name }}</span> <span class="size-2 shrink-0 rounded-full" [style.background-color]="opt.color" [attr.title]="opt.name" ></span> @if (opt.isCustom) { <span ui-badge variant="outline" class="text-xs">Custom</span> } </div> <div class="text-muted-foreground text-xs font-medium">{{ opt.architecture }}</div> <p class="text-muted-foreground/80 line-clamp-1 max-w-[220px] text-xs">{{ opt.summary }}</p> </div> </td> @if (activeFramework() === 'weighted-matrix') { @for (criterion of criteria; track criterion.key) { <td ui-table-cell class="text-center"> <div class="inline-flex flex-col items-center gap-0.5"> <span ui-badge [variant]="scoreBadgeVariant(opt.scores[criterion.key])" class="px-2 py-0.5 text-xs font-semibold tabular-nums" > {{ opt.scores[criterion.key].toFixed(1) }} </span> <div class="mt-1 flex items-center gap-0.5"> @for (seg of [1, 2, 3, 4, 5]; track seg) { <span [class]="segmentClass(opt.scores[criterion.key], seg)"></span> } </div> </div> </td> } <td ui-table-cell class="text-center"> <div class="inline-flex flex-col items-center gap-1"> <span [class]="weightedScoreClass(opt)" >{{ opt.weightedScore }}<span class="text-muted-foreground text-xs font-normal">/ 100</span></span > <div class="bg-muted h-1.5 w-20 overflow-hidden rounded-full"> <div [class]="weightedBarClass(opt)" [style.width.%]="opt.weightedScore"></div> </div> </div> </td> } @else { <td ui-table-cell class="text-center text-xs font-semibold tabular-nums">{{ opt.rice.reach }}%</td> <td ui-table-cell class="text-center text-xs font-semibold tabular-nums"> {{ opt.rice.impact.toFixed(1) }}x </td> <td ui-table-cell class="text-center text-xs font-semibold tabular-nums"> {{ opt.rice.confidence }}% </td> <td ui-table-cell class="text-center text-xs font-semibold tabular-nums"> {{ opt.rice.effort.toFixed(1) }} sprints </td> <td ui-table-cell class="text-center"> <div class="inline-flex flex-col items-center"> <span class="text-primary text-base font-semibold tabular-nums">{{ opt.riceScore }}</span> <span class="text-muted-foreground text-xs font-medium">RICE Pts</span> </div> </td> } <td ui-table-cell class="text-center"> @if (opt.rank === 1) { <span class="border-success/30 bg-success/10 text-success inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold" > <lucide-icon [img]="Award" class="size-3.5" />Recommended Winner </span> } @else if (opt.rank === 2) { <span class="border-info/30 bg-info/10 text-info inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold" > <lucide-icon [img]="CheckCircle2" class="size-3.5" />Second Choice </span> } @else if (opt.rank === 3) { <span class="border-border bg-muted/60 text-muted-foreground inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium" > <lucide-icon [img]="HelpCircle" class="size-3.5" />Alternative </span> } @else { <span class="border-destructive/20 bg-destructive/10 text-destructive inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium" > <lucide-icon [img]="XCircle" class="size-3.5" />Discarded </span> } </td> <td ui-table-cell class="text-center"> @if (opt.isCustom) { <button ui-button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-destructive" title="Remove Custom Option" (click)="removeOption(opt.id); $event.stopPropagation()" > <lucide-icon [img]="Trash2" class="size-3.5" /> </button> } @else { <button ui-button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-foreground" [attr.title]="'Inspect ' + opt.name" (click)="selectedOptionId.set(opt.id); $event.stopPropagation()" > <lucide-icon [img]="Info" class="size-3.5" /> </button> } </td> </tr> } </tbody> </table> </div> <div ui-card-footer class="border-border bg-muted/20 text-muted-foreground flex flex-wrap items-center justify-between gap-3 border-t px-6 py-3 text-xs" > <div class="flex items-center gap-2"> <span class="bg-success inline-block size-2 rounded-full"></span> <span>Scores normalize dynamically to a 0–100 scale based on active criteria weights.</span> </div> <div class="flex items-center gap-3"> <button ui-button variant="ghost" size="xs" class="text-muted-foreground gap-1 text-xs" (click)="resetOptions()" > <lucide-icon [img]="RefreshCw" class="size-3" />Reset Matrix Options </button> </div> </div> </div> <!-- Tradeoff Visualizer & ADR Recommendation Section --> <div class="grid grid-cols-1 gap-6 lg:grid-cols-12"> <!-- Radar Chart --> <div ui-card class="border-border bg-card flex flex-col shadow-xs lg:col-span-6"> <div ui-card-header class="pb-2"> <div class="flex items-center justify-between gap-x-2"> <div class="space-y-0.5"> <h3 ui-card-title class="flex items-center gap-2 text-base font-semibold tracking-tight"> <lucide-icon [img]="BarChart3" class="text-primary size-4" />Decision Tradeoff Radar </h3> <p ui-card-description class="text-xs"> Multi-dimensional polygon overlay comparing architectural options. </p> </div> <span ui-badge variant="outline" class="text-xs font-normal">5 Axes (1-5)</span> </div> </div> <div ui-card-content class="flex flex-1 flex-col items-center justify-center p-4"> <div class="relative flex aspect-square w-full max-w-[340px] items-center justify-center"> <svg viewBox="0 0 340 310" class="h-full w-full overflow-visible"> @for (ring of [0.2, 0.4, 0.6, 0.8, 1.0]; track ring) { <polygon [attr.points]="ringPolygon(ring)" fill="none" stroke="currentColor" stroke-width="1" class="text-border/50" [attr.stroke-dasharray]="ring === 1.0 ? 'none' : '2,2'" /> } @for (idx of [0, 1, 2, 3, 4]; track idx) { <line [attr.x1]="170" [attr.y1]="155" [attr.x2]="radarPoint(idx, 5).x" [attr.y2]="radarPoint(idx, 5).y" stroke="currentColor" stroke-width="1" class="text-border" /> } @for (opt of scoredOptions(); track opt.id) { @if (opt.id !== activeSelectedOption().id) { <polygon [attr.points]="scoresPolygon(opt.scores)" [attr.fill]="opt.color" fill-opacity="0.08" [attr.stroke]="opt.color" stroke-width="1.5" stroke-opacity="0.45" class="cursor-pointer transition-colors duration-300" (click)="selectedOptionId.set(opt.id)" /> } } <polygon [attr.points]="scoresPolygon(activeSelectedOption().scores)" [attr.fill]="activeSelectedOption().color" fill-opacity="0.25" [attr.stroke]="activeSelectedOption().color" stroke-width="2.5" class="transition-colors duration-300" /> @for (val of selectedScoreValues(); track $index) { <circle [attr.cx]="radarPoint($index, val).x" [attr.cy]="radarPoint($index, val).y" r="4" [attr.fill]="activeSelectedOption().color" class="stroke-background stroke-2 transition-colors duration-300" /> } @for (axis of radarLabels; track axis.index) { <text [attr.x]="axis.x" [attr.y]="axis.y" [attr.text-anchor]="axis.anchor" class="fill-foreground text-xs font-semibold select-none" style="font-size: 11px" > {{ axis.label }} </text> } </svg> </div> <div class="border-border flex w-full flex-wrap items-center justify-center gap-3 border-t pt-3"> @for (opt of scoredOptions(); track opt.id) { <button type="button" [class]="legendClass(opt.id)" (click)="selectedOptionId.set(opt.id)"> <span class="size-2.5 shrink-0 rounded-full" [style.background-color]="opt.color"></span> <span class="max-w-[120px] truncate">{{ opt.name.split(':')[0] }}</span> </button> } </div> </div> </div> <!-- Consensus & Tradeoff Detail Card --> <div ui-card class="border-border bg-card flex flex-col justify-between shadow-xs lg:col-span-6"> <div ui-card-header class="pb-3"> <div class="flex flex-wrap items-start justify-between gap-3"> <div class="min-w-0 space-y-1"> <div class="flex flex-wrap items-center gap-2"> <span ui-badge variant="outline" class="text-xs">Selected Option Details</span> <span ui-badge [variant]="activeSelectedOption().verdictVariant === 'success' ? 'success' : 'secondary'" class="text-xs font-semibold" > {{ activeSelectedOption().verdict }} </span> </div> <h3 ui-card-title class="text-foreground text-lg font-semibold tracking-tight"> {{ activeSelectedOption().name }} </h3> <p ui-card-description class="text-primary text-xs font-medium"> {{ activeSelectedOption().architecture }} </p> </div> <div class="shrink-0 text-right"> <div class="text-foreground text-2xl font-semibold tabular-nums"> {{ activeFramework() === 'rice' ? activeSelectedOption().riceScore : activeSelectedOption().weightedScore }} </div> <div class="text-muted-foreground text-xs"> {{ activeFramework() === 'rice' ? 'RICE Score' : 'Weighted Total' }} </div> </div> </div> </div> <div ui-card-content class="space-y-4 text-xs"> <p class="text-muted-foreground text-sm leading-relaxed">{{ activeSelectedOption().summary }}</p> <ui-separator /> <div class="grid grid-cols-1 gap-3.5 sm:grid-cols-2"> <div class="border-success/20 bg-success/5 space-y-2 rounded-lg border p-3"> <span class="text-success flex items-center gap-1.5 text-xs font-semibold"> <lucide-icon [img]="Check" class="size-3.5" />Strategic Advantages </span> <ul class="text-muted-foreground space-y-1.5"> @for (pro of activeSelectedOption().pros; track pro) { <li class="flex items-start gap-1.5"> <span class="text-success mt-0.5">•</span><span>{{ pro }}</span> </li> } </ul> </div> <div class="border-destructive/20 bg-destructive/5 space-y-2 rounded-lg border p-3"> <span class="text-destructive flex items-center gap-1.5 text-xs font-semibold"> <lucide-icon [img]="AlertCircle" class="size-3.5" />Compromises & Friction </span> <ul class="text-muted-foreground space-y-1.5"> @for (con of activeSelectedOption().cons; track con) { <li class="flex items-start gap-1.5"> <span class="text-destructive mt-0.5">•</span><span>{{ con }}</span> </li> } </ul> </div> </div> <div class="border-border bg-muted/40 space-y-1 rounded-md border p-3"> <span class="text-foreground flex items-center gap-1.5 font-semibold"> <lucide-icon [img]="ShieldCheck" class="text-primary size-3.5" />Risk Mitigation & Governance Policy: </span> <p class="text-muted-foreground">{{ activeSelectedOption().keyRisk }}</p> </div> </div> <div ui-card-footer class="border-border bg-muted/20 flex items-center justify-between gap-x-2 border-t px-6 py-3" > <div class="flex items-center gap-2"> <lucide-icon [img]="FileCode2" class="text-muted-foreground size-4" /> <span class="text-muted-foreground text-xs font-medium">ADR Record Consensus: </span> <span class="text-foreground text-xs font-semibold">{{ consensusName() }}</span> </div> <span ui-badge variant="outline" class="gap-1 text-xs"> <lucide-icon [img]="Award" class="text-success size-3" />Rank #{{ activeSelectedOption().rank }} </span> </div> </div> </div> </div> `, }) export class UiDecisionMatrixTableComponent { protected readonly AlertCircle = AlertCircle protected readonly Award = Award protected readonly BarChart3 = BarChart3 protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly ChevronDown = ChevronDown protected readonly ChevronUp = ChevronUp protected readonly FileCode2 = FileCode2 protected readonly HelpCircle = HelpCircle protected readonly Info = Info protected readonly Plus = Plus protected readonly PlusCircle = PlusCircle protected readonly RefreshCw = RefreshCw protected readonly Scale = Scale protected readonly ShieldCheck = ShieldCheck protected readonly SlidersHorizontal = SlidersHorizontal protected readonly Trash2 = Trash2 protected readonly XCircle = XCircle protected readonly criteria = criteriaList protected readonly radarLabels = radarAxisLabels @Input() title = 'Architectural Decision Matrix (RICE / Weighted Scoring)' @Input() subtitle = 'Evaluate technical options against weighted business and engineering criteria.' @Input() set defaultFramework(v: DecisionFrameworkType) { if (v) this.activeFramework.set(v) } @Input('class') className?: string readonly activeFramework = signal<DecisionFrameworkType>('weighted-matrix') readonly showWeightEditor = signal(false) readonly showAddOptionForm = signal(false) readonly selectedOptionId = signal('opt-a') readonly criterionWeights = signal<Record<string, number>>({ impact: 25, confidence: 25, effort: 15, dx: 20, maintenance: 15, }) readonly options = signal<DecisionMatrixOption[]>( initialOptions.map((o) => ({ ...o, scores: { ...o.scores }, rice: { ...o.rice }, pros: [...o.pros], cons: [...o.cons], })), ) readonly newOptionName = signal('') readonly newOptionParadigm = signal('') readonly newOptionSummary = signal('') readonly newOptionScores = signal({ impact: 4.0, confidence: 4.0, effort: 3.5, dx: 4.0, maintenance: 3.5 }) readonly totalWeight = computed(() => Object.values(this.criterionWeights()).reduce((sum, w) => sum + (Number(w) || 0), 0), ) readonly scoredOptions = computed<ScoredDecisionOption[]>(() => { const weights = this.criterionWeights() const sumW = this.totalWeight() || 100 const framework = this.activeFramework() const calculated = this.options().map((opt) => { const weightedSum = opt.scores.impact * (weights['impact'] ?? 25) + opt.scores.confidence * (weights['confidence'] ?? 25) + opt.scores.effort * (weights['effort'] ?? 15) + opt.scores.dx * (weights['dx'] ?? 20) + opt.scores.maintenance * (weights['maintenance'] ?? 15) const weightedScore = Number(((weightedSum / (5 * sumW)) * 100).toFixed(1)) const riceScore = Number( ((opt.rice.reach * opt.rice.impact * (opt.rice.confidence / 100)) / Math.max(0.5, opt.rice.effort)).toFixed(1), ) return { ...opt, weightedScore, riceScore, rank: 0, verdict: 'Discarded' as const, verdictVariant: 'destructive' as const, } }) calculated.sort((a, b) => (framework === 'rice' ? b.riceScore - a.riceScore : b.weightedScore - a.weightedScore)) return calculated.map((item, index) => { const rank = index + 1 let verdict: ScoredDecisionOption['verdict'] = 'Discarded' let verdictVariant: ScoredDecisionOption['verdictVariant'] = 'destructive' if (rank === 1) { verdict = 'Recommended Winner' verdictVariant = 'success' } else if (rank === 2) { verdict = 'Second Choice' verdictVariant = 'info' } else if (rank === 3) { verdict = 'Alternative' verdictVariant = 'warning' } return { ...item, rank, verdict, verdictVariant } }) }) readonly winningOption = computed(() => this.scoredOptions()[0]!) readonly activeSelectedOption = computed( () => this.scoredOptions().find((opt) => opt.id === this.selectedOptionId()) ?? this.winningOption(), ) readonly selectedScoreValues = computed(() => { const s = this.activeSelectedOption().scores return [s.impact, s.confidence, s.effort, s.dx, s.maintenance] }) readonly consensusName = computed(() => { const name = this.winningOption().name return name.split(':')[1] || name }) get rootClass(): string { return cn('bg-background text-foreground w-full space-y-6', this.className) } get weightBadgeClass(): string { return cn( 'ml-0.5 px-1.5 py-0 text-xs tabular-nums', this.totalWeight() === 100 ? 'text-foreground' : 'bg-warning/20 text-warning font-semibold', ) } get totalWeightClass(): string { return cn( 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums', this.totalWeight() === 100 ? 'bg-success/10 text-success' : 'bg-warning/15 text-warning font-bold', ) } frameworkButtonClass(framework: DecisionFrameworkType): string { return cn( 'focus-visible:ring-ring rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none', this.activeFramework() === framework ? 'bg-background text-foreground font-semibold shadow-xs' : 'text-muted-foreground hover:text-foreground', ) } matrixRowClass(opt: ScoredDecisionOption): string { return cn( 'cursor-pointer transition-colors duration-150', opt.id === this.selectedOptionId() ? 'bg-primary/5 dark:bg-primary/10' : 'hover:bg-muted/40', opt.rank === 1 && 'font-medium', ) } rankClass(rank: number): string { return cn( 'inline-flex size-6 items-center justify-center rounded-full text-xs font-semibold tabular-nums', rank === 1 ? 'bg-success text-white shadow-xs' : rank === 2 ? 'bg-info/15 text-info font-semibold' : 'bg-muted text-muted-foreground font-normal', ) } scoreBadgeVariant(score: number): 'success' | 'default' | 'secondary' | 'outline' { if (score >= 4.5) return 'success' if (score >= 3.5) return 'default' if (score >= 2.5) return 'secondary' return 'outline' } segmentClass(score: number, seg: number): string { return cn('size-1 rounded-full', seg <= Math.round(score) ? 'bg-primary' : 'bg-muted-foreground/20') } weightedScoreClass(opt: ScoredDecisionOption): string { return cn( 'text-base font-semibold tracking-tight tabular-nums', opt.rank === 1 ? 'text-success' : opt.weightedScore >= 70 ? 'text-foreground' : 'text-muted-foreground', ) } weightedBarClass(opt: ScoredDecisionOption): string { return cn( 'h-full rounded-full transition-[width,background-color] duration-300', opt.rank === 1 ? 'bg-success' : opt.weightedScore >= 70 ? 'bg-info' : opt.weightedScore >= 50 ? 'bg-warning' : 'bg-destructive', ) } legendClass(id: string): string { return cn( 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors', id === this.activeSelectedOption().id ? 'bg-muted text-foreground ring-border font-semibold ring-1' : 'text-muted-foreground hover:text-foreground opacity-80', ) } radarPoint(axisIndex: number, scoreValue: number, maxVal = 5): { x: number; y: number } { const angle = -Math.PI / 2 + (axisIndex * 2 * Math.PI) / radarAxesCount const ratio = Math.min(1, Math.max(0, scoreValue / maxVal)) const r = radarRadius * ratio return { x: radarCenter.x + r * Math.cos(angle), y: radarCenter.y + r * Math.sin(angle) } } scoresPolygon(scores: DecisionMatrixOption['scores']): string { return [scores.impact, scores.confidence, scores.effort, scores.dx, scores.maintenance] .map((val, idx) => { const pt = this.radarPoint(idx, val) return `${pt.x.toFixed(1)},${pt.y.toFixed(1)}` }) .join(' ') } ringPolygon(ratio: number): string { return Array.from({ length: radarAxesCount }) .map((_, idx) => { const pt = this.radarPoint(idx, ratio * 5) return `${pt.x.toFixed(1)},${pt.y.toFixed(1)}` }) .join(' ') } setWeight(key: string, vals: number[]): void { this.criterionWeights.update((prev) => ({ ...prev, [key]: vals[0] ?? 0 })) } setNewScore(key: keyof DecisionMatrixOption['scores'], vals: number[]): void { this.newOptionScores.update((prev) => ({ ...prev, [key]: vals[0] ?? 3 })) } resetWeights(): void { const fresh: Record<string, number> = {} criteriaList.forEach((c) => { fresh[c.key] = c.defaultWeight }) this.criterionWeights.set(fresh) } resetOptions(): void { this.options.set( initialOptions.map((o) => ({ ...o, scores: { ...o.scores }, rice: { ...o.rice }, pros: [...o.pros], cons: [...o.cons], })), ) this.selectedOptionId.set('opt-a') } handleAddOption(): void { const name = this.newOptionName().trim() if (!name) return const scores = { ...this.newOptionScores() } const newId = `opt-custom-${Date.now()}` const created: DecisionMatrixOption = { id: newId, name, architecture: this.newOptionParadigm().trim() || 'Custom Technical Architecture', summary: this.newOptionSummary().trim() || 'Custom evaluated technical approach for this architectural decision.', keyRisk: 'Requires comprehensive proof-of-concept testing in staging environments.', color: '#06b6d4', pros: ['Tailored to immediate domain constraints', 'Custom architectural design'], cons: ['Needs dedicated long-term ownership and testing framework'], scores, rice: { reach: 75, impact: scores.impact, confidence: Number((scores.confidence * 20).toFixed(0)), effort: Number((6 - scores.effort).toFixed(1)), }, isCustom: true, } this.options.update((prev) => [...prev, created]) this.selectedOptionId.set(newId) this.newOptionName.set('') this.newOptionParadigm.set('') this.newOptionSummary.set('') this.showAddOptionForm.set(false) } removeOption(id: string): void { this.options.update((prev) => prev.filter((opt) => opt.id !== id)) if (this.selectedOptionId() === id) { this.selectedOptionId.set(this.options()[0]?.id ?? '') } } }src/app/components/blocks/decision-matrix-table/index.ts0.2 kB
export { UiDecisionMatrixTableComponent, type DecisionCriterionDef, type DecisionFrameworkType, type DecisionMatrixOption, type ScoredDecisionOption, } from './decision-matrix-table.component'
Raw manifest:https://uipkge.dev/r/angular/decision-matrix-table.json