Menu

Change language

GitHub repo

Synthetic Data Generator

blockai

Gretel and Tonic-style LLM and mathematical synthetic dataset generator with differential privacy epsilon controls, custom schema field builders, K-anonymity policy toggles, live tabular CSV preview, and streaming JSON Lines export.

Installation

$npx uipkge-ng@latest add synthetic-data-generator
Manifest:https://uipkge.dev/r/angular/synthetic-data-generator.jsonInstalls to:src/app/components/blocks/synthetic-data-generator/

Variants

Loading interactive previews…

Schema

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

SchemaField
interface SchemaField {
  id: string
  name: string
  label: string
  type: FieldDataType
  description?: string
  params: {
    mean?: number
    stdDev?: number
    currency?: string
    unit?: string
    categories?: string[]
    weights?: number[]
    domain?: string
    dateStart?: string
    dateEnd?: string
  }
}
TemplatePreset
interface TemplatePreset {
  id: string
  name: string
  description: string
  category: string
  fields: SchemaField[]
}
GeneratedRecord
interface GeneratedRecord {
  id: string
  [key: string]: string | number
}
FidelityMetrics
interface FidelityMetrics {
  similarity: number
  leakageRisk: string
  correlationRetention: number
  wassersteinDistance: number
  laplaceNoiseStd: string
  privacyTier: string
  privacyBadgeColor: string
}

Files installed (5)

  • src/app/components/blocks/synthetic-data-generator/synthetic-data-generator.component.ts35.5 kB
    import {
      Component,
      DestroyRef,
      Input,
      type OnInit,
      computed,
      inject,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import {
      Check,
      Code2,
      Cpu,
      Database,
      Download,
      FileSpreadsheet,
      Info,
      Layers,
      Lock,
      LucideAngularModule,
      Plus,
      RefreshCw,
      ShieldCheck,
      Sliders,
      Sparkles,
      Trash2,
    } 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,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    import type {
      FieldDataType,
      FidelityMetrics,
      GeneratedRecord,
      SchemaField,
      SyntheticDataGeneratorProps,
      TemplatePreset,
    } from './synthetic-data-types'
    import {
      boxMullerRandom as boxMullerRandomHelper,
      CARD_BRANDS,
      FIRST_NAMES,
      LAST_NAMES,
      TEMPLATES,
    } from './synthetic-data-templates'
    import { UiSyntheticDataPreviewComponent, type SyntheticDataPreviewTab } from './synthetic-data-preview.component'
    
    export type {
      FieldDataType,
      FidelityMetrics,
      GeneratedRecord,
      SchemaField,
      SyntheticDataGeneratorProps,
      TemplatePreset,
    }
    
    const ROW_PRESETS = [100, 1000, 5000, 10000, 50000]
    const EPSILON_PRESETS = [
      { eps: 0.5, label: 'Healthcare' },
      { eps: 1.2, label: 'Standard' },
      { eps: 3.0, label: 'Analytics' },
    ]
    const FIELD_TYPE_BADGE: Record<FieldDataType, string> = {
      gaussian_float: 'border-info/20 bg-info/10 text-info',
      name: 'border-chart-1/20 bg-chart-1/10 text-chart-1',
      email: 'border-chart-2/20 bg-chart-2/10 text-chart-2',
      credit_card: 'border-warning/20 bg-warning/10 text-warning',
      category_weights: 'border-success/20 bg-success/10 text-success',
      date_range: 'border-destructive/20 bg-destructive/10 text-destructive',
    }
    const FORMAT_BUTTON = 'flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium transition-colors'
    
    function cloneFields(key: string): SchemaField[] {
      const tpl = TEMPLATES[key]
      return tpl?.fields ? (JSON.parse(JSON.stringify(tpl.fields)) as SchemaField[]) : []
    }
    
    /**
     * Synthetic dataset generator & privacy engine (Gretel / Tonic style). Port of the React
     * `SyntheticDataGenerator` 1:1: schema template picker, CSV / JSON Lines format toggle, row
     * volume slider + presets, differential-privacy epsilon budget, a custom schema field builder,
     * PII anonymization switches, and the live preview column (`ui-synthetic-data-preview`).
     * `initialTemplate`, `initialRowCount` and `initialEpsilon` seed the state once.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-synthetic-data-generator, [ui-synthetic-data-generator]',
      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,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiSliderComponent,
        UiSwitchComponent,
        UiSyntheticDataPreviewComponent,
      ],
      template: `
        <div data-slot="synthetic-data-generator" [class]="rootClass">
          <!-- TOP HEADER HERO -->
          <div ui-card class="border-border overflow-hidden shadow-xs">
            <div ui-card-header class="border-border/60 bg-muted/20 border-b pb-4">
              <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
                <div class="space-y-1">
                  <div class="flex flex-wrap items-center gap-2">
                    <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                      <lucide-icon [img]="Cpu" class="size-4.5" />
                    </div>
                    <h3 ui-card-title class="text-lg font-bold tracking-tight md:text-xl">
                      Synthetic Dataset Generator &amp; Privacy Engine
                    </h3>
                    <span ui-badge variant="outline" class="gap-1 text-xs font-normal">
                      <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                      Differential Privacy Enabled
                    </span>
                  </div>
                  <p ui-card-description class="text-xs md:text-sm">
                    Mathematical &amp; LLM-guided synthetic tabular dataset synthesizer with configurable (ε, δ)-DP noise
                    injection.
                  </p>
                </div>
    
                <!-- Header Right Quick Actions -->
                <div class="flex flex-wrap items-center gap-2.5">
                  <!-- Format Selector Segmented Control -->
                  <div class="bg-muted/60 border-border inline-flex items-center rounded-lg border p-0.5 text-xs">
                    <button type="button" [class]="formatButtonClass('csv')" (click)="outputFormat.set('csv')">
                      <lucide-icon [img]="FileSpreadsheet" class="size-3.5" />
                      <span>Tabular CSV</span>
                    </button>
                    <button type="button" [class]="formatButtonClass('jsonl')" (click)="outputFormat.set('jsonl')">
                      <lucide-icon [img]="Code2" class="size-3.5" />
                      <span>JSON Lines</span>
                    </button>
                  </div>
    
                  <!-- Download Button -->
                  <button
                    ui-button
                    aria-label="Download attachment"
                    variant="outline"
                    size="sm"
                    class="h-9 gap-1.5 text-xs font-medium"
                    (click)="handleDownloadDataset()"
                  >
                    <lucide-icon [img]="Download" class="size-3.5" />
                    <span>Download Dataset</span>
                  </button>
    
                  <!-- Primary Generate Batch Button -->
                  <button
                    ui-button
                    variant="default"
                    size="sm"
                    class="h-9 gap-1.5 px-4 text-xs font-semibold shadow-xs transition-[color,background-color,border-color,box-shadow,opacity,transform,scale,translate,rotate] active:scale-95"
                    [disabled]="isGenerating()"
                    (click)="triggerRegeneration()"
                  >
                    @if (isGenerating()) {
                      <!-- [class] (not class=) feeds only the svg, so the host does not spin as well. -->
                      <lucide-icon [img]="RefreshCw" [class]="'size-3.5 animate-spin'" />
                    } @else {
                      <lucide-icon [img]="Sparkles" class="size-3.5" />
                    }
                    <span>{{ isGenerating() ? 'Synthesizing...' : 'Generate Synthetic Batch' }}</span>
                  </button>
                </div>
              </div>
    
              <!-- Template Selector Bar -->
              <div
                class="border-border/50 bg-background/50 mt-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3.5 py-2"
              >
                <div class="flex items-center gap-2">
                  <lucide-icon [img]="Layers" class="text-muted-foreground size-4" />
                  <span class="text-foreground text-xs font-semibold">Schema Template:</span>
                </div>
    
                <div class="w-full min-w-0 sm:max-w-md sm:flex-1">
                  <ui-select [value]="selectedTemplateKey()" (valueChange)="handleTemplateChange($event)">
                    <button
                      ui-select-trigger
                      class="bg-background h-8.5 w-full text-xs font-medium [&>span]:truncate [&>svg]:shrink-0"
                    >
                      <span ui-select-value placeholder="Select Schema Template"></span>
                    </button>
                    <ui-select-content>
                      @for (tpl of templates; track tpl[0]) {
                        <ui-select-item [value]="tpl[0]" class="text-xs">{{ tpl[1].name }}</ui-select-item>
                      }
                    </ui-select-content>
                  </ui-select>
                </div>
    
                <div class="text-muted-foreground hidden items-center gap-2 text-xs sm:flex">
                  <span ui-badge variant="secondary" class="px-2 py-0.5 text-xs font-normal">
                    {{ templateCategory() }}
                  </span>
                  <span>•</span>
                  <span class="font-mono tabular-nums">{{ activeFields().length }} fields configured</span>
                </div>
              </div>
            </div>
          </div>
    
          <!-- DOWNLOAD SUCCESS NOTIFICATION -->
          @if (downloadToast(); as toast) {
            <div
              class="border-success/30 bg-success/10 text-success text-success flex items-center justify-between rounded-lg border px-4 py-2.5 text-xs font-medium shadow-xs"
            >
              <div class="flex items-center gap-2">
                <lucide-icon [img]="Check" class="size-4 stroke-[2.5]" />
                <span>{{ toast }}</span>
              </div>
              <span ui-badge variant="outline" class="border-success/40 font-mono text-xs">
                {{ estimatedFileSize() }}
              </span>
            </div>
          }
    
          <!-- 2-COLUMN STUDIO GENERATOR -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- LEFT COLUMN: SCHEMA & PRIVACY CONFIGURATION (40% / 5 Cols) -->
            <div class="space-y-6 lg:col-span-5">
              <!-- CARD 1: ROW COUNT & BATCH SCALE -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="border-border/50 border-b pb-3">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="Database" class="size-3.5" />
                      </div>
                      <h3 ui-card-title class="text-sm font-semibold">Dataset Generation Volume</h3>
                    </div>
                    <span ui-badge variant="secondary" class="font-mono text-xs font-semibold tabular-nums">
                      {{ rowCount().toLocaleString() }} rows
                    </span>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4 pt-4">
                  <!-- Row Slider -->
                  <div class="space-y-2">
                    <div class="text-muted-foreground flex items-center justify-between text-xs">
                      <span>100 rows</span>
                      <span class="text-foreground font-mono font-medium">Estimated: {{ estimatedFileSize() }}</span>
                      <span>50,000 rows</span>
                    </div>
    
                    <ui-slider
                      [value]="[rowCount()]"
                      [min]="100"
                      [max]="50000"
                      [step]="100"
                      class="w-full py-1"
                      (valueChange)="onRowCountChange($event)"
                    />
                  </div>
    
                  <!-- Quick Preset Chips -->
                  <div class="flex flex-wrap items-center gap-1.5 pt-1">
                    <span class="text-muted-foreground mr-1 text-xs">Presets:</span>
                    @for (preset of rowPresets; track preset) {
                      <button type="button" [class]="rowPresetClass(preset)" (click)="rowCount.set(preset)">
                        {{ presetLabel(preset) }}
                      </button>
                    }
                  </div>
                </div>
              </div>
    
              <!-- CARD 2: DIFFERENTIAL PRIVACY EPSILON (ε) CONTROLS -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="border-border/50 border-b pb-3">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="ShieldCheck" class="size-3.5" />
                      </div>
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Differential Privacy Budget</h3>
                        <p ui-card-description class="text-xs">Laplace (ε, δ)-DP noise calibration</p>
                      </div>
                    </div>
                    <span ui-badge variant="outline" class="font-mono text-xs font-bold tabular-nums">
                      ε = {{ epsilon().toFixed(1) }}
                    </span>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4 pt-4">
                  <!-- Epsilon Slider -->
                  <div class="space-y-2">
                    <div class="flex items-center justify-between text-xs">
                      <span class="text-success font-medium">ε=0.1 (High Privacy)</span>
                      <span class="text-warning font-medium">ε=5.0 (High Fidelity)</span>
                    </div>
    
                    <ui-slider
                      [value]="[epsilon()]"
                      [min]="0.1"
                      [max]="5.0"
                      [step]="0.1"
                      class="w-full py-1"
                      (valueChange)="onEpsilonChange($event)"
                    />
                  </div>
    
                  <!-- Epsilon Interpretation Banner -->
                  <div [class]="privacyBannerClass()">
                    <!-- [class] (not class=) feeds only the svg, so mt-0.5 is not applied twice (host + svg). -->
                    <lucide-icon [img]="Info" [class]="'mt-0.5 size-4 shrink-0'" />
                    <div class="space-y-1">
                      <div class="font-semibold">{{ fidelityMetrics().privacyTier }}</div>
                      <p class="text-muted-foreground text-xs leading-relaxed">
                        Laplace noise scale:
                        <span class="text-foreground font-mono font-medium"
                          >σ=±{{ fidelityMetrics().laplaceNoiseStd }}</span
                        >
                        • Delta: <span class="text-foreground font-mono font-medium">δ=10⁻⁵</span> • Zero memorization
                        guarantee.
                      </p>
                    </div>
                  </div>
    
                  <!-- Quick Epsilon Presets -->
                  <div class="grid grid-cols-3 gap-2 pt-1">
                    @for (p of epsilonPresets; track p.eps) {
                      <button type="button" [class]="epsilonPresetClass(p.eps)" (click)="epsilon.set(p.eps)">
                        <div class="font-bold">ε = {{ p.eps.toFixed(1) }}</div>
                        <div class="text-muted-foreground text-xs">{{ p.label }}</div>
                      </button>
                    }
                  </div>
                </div>
              </div>
    
              <!-- CARD 3: FIELD SCHEMAS GENERATOR -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="border-border/50 border-b pb-3">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="Sliders" class="size-3.5" />
                      </div>
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Schema Fields Generator</h3>
                        <p ui-card-description class="text-xs">
                          {{ activeFields().length }} active synthesizer distributions
                        </p>
                      </div>
                    </div>
                    <button
                      ui-button
                      variant="outline"
                      size="sm"
                      class="h-7.5 gap-1 px-2.5 text-xs"
                      (click)="showAddField.set(!showAddField())"
                    >
                      <lucide-icon [img]="Plus" class="size-3.5" />
                      <span>{{ showAddField() ? 'Cancel' : 'Add Field' }}</span>
                    </button>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-3 pt-4">
                  <!-- Add Field Form -->
                  @if (showAddField()) {
                    <div class="border-border/80 bg-muted/30 space-y-3 rounded-lg border p-3.5 text-xs">
                      <div class="text-foreground font-semibold">Configure New Synthetic Field</div>
                      <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
                        <div class="space-y-1">
                          <label class="text-muted-foreground text-xs">Field Identifier</label>
                          <ui-input
                            [value]="newFieldName()"
                            (valueChange)="newFieldName.set($event)"
                            placeholder="e.g. account_balance"
                            class="h-8 text-xs"
                          />
                        </div>
                        <div class="space-y-1">
                          <label class="text-muted-foreground text-xs">Data Distribution Type</label>
                          <ui-select [value]="newFieldType()" (valueChange)="setNewFieldType($event)">
                            <button ui-select-trigger class="bg-background h-8 w-full min-w-0 text-xs">
                              <span ui-select-value placeholder="Type"></span>
                            </button>
                            <ui-select-content>
                              <ui-select-item value="gaussian_float" class="text-xs"
                                >Float Gaussian Distribution</ui-select-item
                              >
                              <ui-select-item value="name" class="text-xs">Full Name Faker</ui-select-item>
                              <ui-select-item value="email" class="text-xs">Email Faker</ui-select-item>
                              <ui-select-item value="credit_card" class="text-xs">Credit Card Faker</ui-select-item>
                              <ui-select-item value="category_weights" class="text-xs">Category Weights</ui-select-item>
                              <ui-select-item value="date_range" class="text-xs">Date Range</ui-select-item>
                            </ui-select-content>
                          </ui-select>
                        </div>
                      </div>
    
                      <div class="flex justify-end gap-2 pt-1">
                        <button ui-button variant="ghost" size="sm" class="h-7.5 text-xs" (click)="showAddField.set(false)">
                          Cancel
                        </button>
                        <button
                          ui-button
                          variant="default"
                          size="sm"
                          class="h-7.5 text-xs"
                          [disabled]="!newFieldName().trim()"
                          (click)="handleAddCustomField()"
                        >
                          Add to Schema
                        </button>
                      </div>
                    </div>
                  }
    
                  <!-- Fields List -->
                  <div class="divide-border/60 max-h-[320px] space-y-2 overflow-y-auto pr-1">
                    @for (field of activeFields(); track field.id) {
                      <div
                        class="group border-border/70 bg-card hover:bg-muted/20 flex items-center justify-between rounded-lg border p-2.5 transition-colors"
                      >
                        <div class="min-w-0 space-y-0.5">
                          <div class="flex items-center gap-2">
                            <span class="text-foreground font-mono text-xs font-semibold">{{ field.name }}</span>
                            <span ui-badge variant="outline" [class]="fieldTypeBadgeClass(field.type)">
                              {{ field.type.replace('_', ' ') }}
                            </span>
                          </div>
                          <p class="text-muted-foreground truncate text-xs">
                            {{ field.description || 'Configured synthesizer parameter' }}
                          </p>
                        </div>
    
                        <button
                          ui-button
                          variant="ghost"
                          size="sm"
                          class="text-muted-foreground hover:text-destructive h-7 w-7 p-0 opacity-80 group-hover:opacity-100"
                          aria-label="Delete field"
                          [disabled]="activeFields().length <= 2"
                          (click)="removeField(field.id)"
                        >
                          <lucide-icon [img]="Trash2" class="size-3.5" />
                        </button>
                      </div>
                    }
                  </div>
                </div>
              </div>
    
              <!-- CARD 4: PII ANONYMIZATION POLICY SWITCHES -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="border-border/50 border-b pb-3">
                  <div class="flex items-center gap-2">
                    <div class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md">
                      <lucide-icon [img]="Lock" class="size-3.5" />
                    </div>
                    <div>
                      <h3 ui-card-title class="text-sm font-semibold">PII Anonymization Policy</h3>
                      <p ui-card-description class="text-xs">Zero linkage &amp; re-identification guarantees</p>
                    </div>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4 pt-4">
                  <!-- K-Anonymity Switch -->
                  <div class="flex items-start justify-between gap-3">
                    <div class="space-y-0.5">
                      <div class="text-foreground text-xs font-semibold">K-Anonymity (k = 5 Cohort Enforcement)</div>
                      <p class="text-muted-foreground text-xs leading-relaxed">
                        Generalizes quasi-identifiers so each demographic bucket contains ≥ 5 records.
                      </p>
                    </div>
                    <button ui-switch [checked]="kAnonymity()" (checkedChange)="kAnonymity.set($event)"></button>
                  </div>
    
                  <div ui-separator></div>
    
                  <!-- Salted Hash Switch -->
                  <div class="flex items-start justify-between gap-3">
                    <div class="space-y-0.5">
                      <div class="text-foreground text-xs font-semibold">Salted HMAC-SHA256 for Primary Keys</div>
                      <p class="text-muted-foreground text-xs leading-relaxed">
                        Deterministically pseudonymizes primary keys with ephemeral cryptographic salt.
                      </p>
                    </div>
                    <button ui-switch [checked]="saltedHash()" (checkedChange)="saltedHash.set($event)"></button>
                  </div>
    
                  <div ui-separator></div>
    
                  <!-- Noise Injection Switch -->
                  <div class="flex items-start justify-between gap-3">
                    <div class="space-y-0.5">
                      <div class="text-foreground text-xs font-semibold">Differential Privacy Gaussian Noise</div>
                      <p class="text-muted-foreground text-xs leading-relaxed">
                        Injects calibrated noise calibrated to global sensitivity (Δf / ε).
                      </p>
                    </div>
                    <button ui-switch [checked]="noiseMechanism()" (checkedChange)="noiseMechanism.set($event)"></button>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- RIGHT COLUMN: LIVE GENERATED SYNTHETIC DATA PREVIEW (60% / 7 Cols) -->
            <div class="space-y-6 lg:col-span-7">
              <ui-synthetic-data-preview
                [previewTab]="previewTab()"
                (previewTabChange)="previewTab.set($event)"
                [previewRecords]="previewRecords()"
                [activeFields]="activeFields()"
                [fidelityMetrics]="fidelityMetrics()"
                [epsilon]="epsilon()"
                [rowCount]="rowCount()"
                [outputFormat]="outputFormat()"
                [generationSeed]="generationSeed()"
                [copied]="copied()"
                (copyJsonLines)="copyJsonLines()"
              />
            </div>
          </div>
        </div>
      `,
    })
    export class UiSyntheticDataGeneratorComponent implements OnInit {
      @Input('class') className?: string
      @Input() initialTemplate = 'customer-transactions'
      @Input() initialRowCount = 1000
      @Input() initialEpsilon = 1.2
    
      // --- STATE ---
      readonly selectedTemplateKey = signal<string>('customer-transactions')
      readonly outputFormat = signal<'csv' | 'jsonl'>('csv')
      readonly rowCount = signal<number>(1000)
      readonly epsilon = signal<number>(1.2)
      readonly kAnonymity = signal<boolean>(true)
      readonly saltedHash = signal<boolean>(true)
      readonly noiseMechanism = signal<boolean>(true)
      readonly activeFields = signal<SchemaField[]>(cloneFields('customer-transactions'))
    
      readonly isGenerating = signal<boolean>(false)
      readonly copied = signal<boolean>(false)
      readonly downloadToast = signal<string | null>(null)
      readonly previewTab = signal<SyntheticDataPreviewTab>('table')
      readonly generationSeed = signal<number>(48291)
    
      // Field addition state
      readonly showAddField = signal<boolean>(false)
      readonly newFieldName = signal<string>('')
      readonly newFieldType = signal<FieldDataType>('gaussian_float')
    
      protected readonly templates = Object.entries(TEMPLATES)
      protected readonly rowPresets = ROW_PRESETS
      protected readonly epsilonPresets = EPSILON_PRESETS
    
      protected readonly Check = Check
      protected readonly Code2 = Code2
      protected readonly Cpu = Cpu
      protected readonly Database = Database
      protected readonly Download = Download
      protected readonly FileSpreadsheet = FileSpreadsheet
      protected readonly Info = Info
      protected readonly Layers = Layers
      protected readonly Lock = Lock
      protected readonly Plus = Plus
      protected readonly RefreshCw = RefreshCw
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Sliders = Sliders
      protected readonly Sparkles = Sparkles
      protected readonly Trash2 = Trash2
    
      private readonly timers = new Set<ReturnType<typeof setTimeout>>()
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t)))
      }
    
      ngOnInit(): void {
        this.selectedTemplateKey.set(this.initialTemplate)
        this.rowCount.set(this.initialRowCount)
        this.epsilon.set(this.initialEpsilon)
        this.activeFields.set(cloneFields(this.initialTemplate))
      }
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-7xl space-y-6', this.className)
      }
    
      protected readonly templateCategory = computed(() => TEMPLATES[this.selectedTemplateKey()]?.category)
    
      // Dynamic preview records computation
      readonly previewRecords = computed<GeneratedRecord[]>(() => {
        const generationSeed = this.generationSeed()
        const saltedHash = this.saltedHash()
        const noiseMechanism = this.noiseMechanism()
        const epsilon = this.epsilon()
        const rows: GeneratedRecord[] = []
        const count = 6
    
        // Box-Muller Gaussian random generator with differential privacy noise
        const boxMullerRandom = (mean: number, stdDev: number, seedOffset: number): number =>
          boxMullerRandomHelper(mean, stdDev, seedOffset, generationSeed, noiseMechanism, epsilon)
    
        for (let i = 0; i < count; i++) {
          const fn = FIRST_NAMES[(i * 3 + generationSeed) % FIRST_NAMES.length]!
          const ln = LAST_NAMES[(i * 5 + generationSeed + 2) % LAST_NAMES.length]!
          const fullName = `${fn} ${ln}`
          const baseSlug = `${fn.charAt(0).toLowerCase()}.${ln.toLowerCase().replace(/[^a-z]/g, '')}`
    
          const record: GeneratedRecord = {
            id: saltedHash
              ? `syn_${((generationSeed * 9301 + i * 49297) % 233280).toString(16).padStart(6, '0')}`
              : `usr_${1000 + i}`,
          }
    
          for (const field of this.activeFields()) {
            if (field.name.includes('id') || field.name.includes('uuid') || field.name.includes('token')) {
              record[field.name] = record.id
              continue
            }
    
            switch (field.type) {
              case 'name':
                record[field.name] = fullName
                break
    
              case 'email': {
                const dom = field.params.domain || 'synthetic-vault.io'
                record[field.name] = `${baseSlug}@${dom}`
                break
              }
    
              case 'credit_card': {
                const last4 = (((generationSeed * 7 + i * 1337 + 1000) % 9000) + 1000).toString()
                const brand = CARD_BRANDS[i % CARD_BRANDS.length]
                record[field.name] = `${brand} •••• ${last4}`
                break
              }
    
              case 'gaussian_float': {
                const m = field.params.mean ?? 100
                const s = field.params.stdDev ?? 20
                const rawVal = boxMullerRandom(m, s, i)
                const val = Math.max(m * 0.1, rawVal)
    
                if (field.params.currency) {
                  record[field.name] = `${field.params.currency}${val.toFixed(2)}`
                } else if (field.params.unit) {
                  record[field.name] = `${val.toFixed(1)} ${field.params.unit}`
                } else {
                  record[field.name] = parseFloat(val.toFixed(2))
                }
                break
              }
    
              case 'category_weights': {
                const cats = field.params.categories || ['Standard', 'Premium']
                const catIndex = (i + Math.floor(generationSeed / 100)) % cats.length
                record[field.name] = cats[catIndex]!
                break
              }
    
              case 'date_range': {
                const day = ((10 + i * 4) % 28) + 1
                const month = (i % 8) + 1
                const dayStr = day.toString().padStart(2, '0')
                const monthStr = month.toString().padStart(2, '0')
                const hour = (8 + i * 3) % 24
                const hourStr = hour.toString().padStart(2, '0')
                record[field.name] = `2025-${monthStr}-${dayStr} ${hourStr}:24:18 UTC`
                break
              }
    
              default:
                record[field.name] = `value_${i}`
            }
          }
    
          rows.push(record)
        }
    
        return rows
      })
    
      // Statistical fidelity metrics
      readonly fidelityMetrics = computed<FidelityMetrics>(() => {
        const epsilon = this.epsilon()
        const rawSimilarity = Math.min(99.4, Math.max(82.0, 99.2 - ((5.0 - epsilon) / 4.9) * 9.6))
        const similarity = parseFloat(rawSimilarity.toFixed(1))
        const leakageRisk =
          this.kAnonymity() && this.saltedHash() && epsilon <= 3.5 ? 0.0 : Math.max(0, (epsilon - 3.5) * 0.04)
        const correlationRetention = parseFloat(Math.min(99.1, 98.6 - ((5.0 - epsilon) / 4.9) * 6.2).toFixed(1))
        const wassersteinDistance = parseFloat((0.032 + ((5.0 - epsilon) / 4.9) * 0.024).toFixed(3))
        const laplaceNoiseStd = (0.12 / Math.max(0.1, epsilon)).toFixed(3)
    
        let privacyTier = 'Balanced Privacy & Utility'
        let privacyBadgeColor = 'bg-info/10 text-info border-info/30'
    
        if (epsilon <= 0.5) {
          privacyTier = 'Maximum Privacy / High DP Noise'
          privacyBadgeColor = 'bg-success/10 text-success border-success/30'
        } else if (epsilon > 2.5) {
          privacyTier = 'High Statistical Utility / Minimal Noise'
          privacyBadgeColor = 'bg-warning/10 text-warning border-warning/30'
        }
    
        return {
          similarity,
          leakageRisk: leakageRisk.toFixed(2),
          correlationRetention,
          wassersteinDistance,
          laplaceNoiseStd,
          privacyTier,
          privacyBadgeColor,
        }
      })
    
      // Estimated file size calculation
      readonly estimatedFileSize = computed(() => {
        const bytesPerRow = this.outputFormat() === 'csv' ? 180 : 380
        const totalKb = (this.rowCount() * bytesPerRow) / 1024
        if (totalKb < 1024) {
          return `~${totalKb.toFixed(1)} KB`
        }
        return `~${(totalKb / 1024).toFixed(2)} MB`
      })
    
      protected formatButtonClass(format: 'csv' | 'jsonl'): string {
        return cn(
          FORMAT_BUTTON,
          this.outputFormat() === format
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      protected rowPresetClass(preset: number): string {
        return cn(
          'cursor-pointer rounded-md border px-2.5 py-1 text-xs font-medium transition-colors',
          this.rowCount() === preset
            ? 'bg-primary text-primary-foreground border-primary shadow-xs'
            : 'bg-muted/40 text-muted-foreground border-border hover:bg-muted hover:text-foreground',
        )
      }
    
      protected presetLabel(preset: number): string {
        return preset >= 1000 ? `${preset / 1000}k` : String(preset)
      }
    
      protected epsilonPresetClass(eps: number): string {
        return cn(
          'cursor-pointer rounded-lg border p-2 text-center text-xs transition-colors',
          this.epsilon() === eps
            ? 'border-primary bg-primary/10 text-primary font-semibold'
            : 'border-border bg-background text-muted-foreground hover:bg-muted/40',
        )
      }
    
      protected privacyBannerClass(): string {
        return cn('flex items-start gap-2.5 rounded-lg border p-3 text-xs', this.fidelityMetrics().privacyBadgeColor)
      }
    
      protected fieldTypeBadgeClass(type: FieldDataType): string {
        return cn('px-1.5 py-0 text-xs font-semibold tracking-wider uppercase', FIELD_TYPE_BADGE[type])
      }
    
      protected onRowCountChange(value: number[]): void {
        if (value[0] !== undefined) this.rowCount.set(value[0])
      }
    
      protected onEpsilonChange(value: number[]): void {
        if (value[0] !== undefined) this.epsilon.set(value[0])
      }
    
      protected setNewFieldType(value: string): void {
        this.newFieldType.set(value as FieldDataType)
      }
    
      // Handle template selection switch
      handleTemplateChange(key: string): void {
        this.selectedTemplateKey.set(key)
        if (TEMPLATES[key]) {
          this.activeFields.set(cloneFields(key))
          this.triggerRegeneration()
        }
      }
    
      // Generation trigger
      triggerRegeneration(): void {
        this.isGenerating.set(true)
        this.generationSeed.set(Math.floor(Math.random() * 90000) + 10000)
        this.later(() => this.isGenerating.set(false), 450)
      }
    
      // Add field handler
      handleAddCustomField(): void {
        const name = this.newFieldName()
        if (!name.trim()) return
    
        const type = this.newFieldType()
        const cleanName = name.trim().toLowerCase().replace(/\s+/g, '_')
        const newField: SchemaField = {
          id: `f-custom-${Date.now()}`,
          name: cleanName,
          label: name.trim(),
          type,
          description: `Custom ${type} schema column`,
          params:
            type === 'gaussian_float'
              ? { mean: 500, stdDev: 120, currency: '$' }
              : type === 'category_weights'
                ? { categories: ['Alpha', 'Beta', 'Gamma'], weights: [0.5, 0.3, 0.2] }
                : {},
        }
    
        this.activeFields.update((prev) => [...prev, newField])
        this.newFieldName.set('')
        this.showAddField.set(false)
        this.triggerRegeneration()
      }
    
      // Remove field handler
      removeField(fieldId: string): void {
        if (this.activeFields().length <= 2) return
        this.activeFields.update((prev) => prev.filter((f) => f.id !== fieldId))
        this.triggerRegeneration()
      }
    
      // Copy JSON lines
      async copyJsonLines(): Promise<void> {
        try {
          const jsonLinesText = this.previewRecords()
            .map((r) => JSON.stringify(r))
            .join('\n')
          if (navigator?.clipboard?.writeText) {
            await navigator.clipboard.writeText(jsonLinesText)
          }
          this.copied.set(true)
          this.later(() => this.copied.set(false), 2000)
        } catch (err) {
          console.error('Failed to copy to clipboard', err)
        }
      }
    
      // Download simulation
      handleDownloadDataset(): void {
        const rowCount = this.rowCount()
        const filename = `synthetic_${this.selectedTemplateKey()}_${rowCount}_rows.${this.outputFormat() === 'csv' ? 'csv' : 'jsonl'}`
        this.downloadToast.set(`Downloaded ${rowCount.toLocaleString()} synthetic records (${filename})`)
        this.later(() => this.downloadToast.set(null), 3500)
      }
    
      private later(fn: () => void, ms: number): void {
        const t = setTimeout(() => {
          this.timers.delete(t)
          fn()
        }, ms)
        this.timers.add(t)
      }
    }
    
  • src/app/components/blocks/synthetic-data-generator/synthetic-data-preview.component.ts17.9 kB
  • src/app/components/blocks/synthetic-data-generator/synthetic-data-types.ts1.1 kB
  • src/app/components/blocks/synthetic-data-generator/synthetic-data-templates.ts8.9 kB
  • src/app/components/blocks/synthetic-data-generator/index.ts0.5 kB

Raw manifest:https://uipkge.dev/r/angular/synthetic-data-generator.json