Menu

Change language

GitHub repo

Decision Matrix Table

blockproductivity

Product 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

$npx uipkge-ng@latest add decision-matrix-table
Manifest:https://uipkge.dev/r/angular/decision-matrix-table.jsonInstalls to:src/app/components/blocks/decision-matrix-table/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

DecisionCriterionDef
interface DecisionCriterionDef {
  key: 'impact' | 'confidence' | 'effort' | 'dx' | 'maintenance'
  label: string
  shortLabel: string
  description: string
  defaultWeight: number
}
DecisionMatrixOption
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
}

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

Raw manifest:https://uipkge.dev/r/angular/decision-matrix-table.json