So I have very weird UI and very unique data visualization using d3 in an angular application. The issue is that my d3 chart will not update no matter what I do to it? I have even tried changing changeDetection: ChangeDetectionStrategy.Default,to Default. I am quite confident that the parent component that has my chips that are used to select and unselect things that need filtered is working properly, but I will include it as well. Here is the parent.component.html: <form [formGroup]="form" novalidate autocomplete="off"> <ng-container *ngIf="lastRouteSegment == 'overview'"> <div style="text-align:left"> <mky-form-field label="Moral Types" [control]="form.controls.moralTypes" [isInline]="false"> <mky-chips formControlName="moralTypes" [items]="moralTypeItems" [triadOfBalance]="triadOfBalance$ | async" [moralElement]="moralElement$ | async" (ngModelChange)="onMoralTypesChange($event)"></mky-chips> </mky-form-field> <mky-form-field label="Elements" [control]="form.controls.elements" [isInline]="false"> <mky-chips formControlName="elements" [items]="elementItems" [triadOfBalance]="triadOfBalance$ | async" [moralElement]="moralElement$ | async" (ngModelChange)="onMoralTypesChange($event)"></mky-chips> </mky-form-field> <mky-form-field label="Relationships" [control]="form.controls.relationships" [isInline]="false"> <mky-chips formControlName="relationships" [items]="relationshipItems" [triadOfBalance]="triadOfBalance$ | async" [moralElement]="moralElement$ | async" (ngModelChange)="onMoralTypesChange($event)"></mky-chips> </mky-form-field> </div> <div class="chart-section"> <app-d3-chart [moralTypes]="moralTypesFilter$ | async" [elements]="elementsFilter$ | async" [relationships]="relationshipsFilter$ | async"> </app-d3-chart> </div> </ng-container> <ng-container *ngIf="lastRouteSegment != 'overview'"> <mky-form-field label="Select Your Moral Type" [required]="true" [control]="form.controls['triadOfBalance']" [isInline]="false" [triadOfBalance]="triadOfBalance$ | async"> <mky-radios formControlName="triadOfBalance" [items]="triadOfBalance" [flexRow]="true"></mky-radios> </mky-form-field> <mky-moral-summary-description [moralElement]="lastRouteSegment" [triadOfBalance]="form.controls['triadOfBalance'].value"></mky-moral-summary-description> </ng-container> </form> Here is the parent.component.ts: import { Component, OnInit, ChangeDetectionStrategy, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { startWith, map, filter, takeUntil, tap } from 'rxjs/operators'; import { d3ChartComponent } from '../d3-chart/d3-chart.component'; import { ControlItem, MkyChipsComponent, MkyFormFieldComponent, MkyRadiosComponent, MoralElementDescriptionComponent } from 'monkey-mat'; import { combineLatest, Observable, Subject } from 'rxjs'; import { select, Store } from '@ngrx/store'; import * as fromRoot from 'store'; import * as fromUITheme from 'store'; // Adjust path as needed import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; @Component({ selector: 'app-overview', templateUrl: './overview.component.html', styleUrls: ['./overview.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ CommonModule, ReactiveFormsModule, MkyFormFieldComponent, MkyChipsComponent, MkyRadiosComponent, MoralElementDescriptionComponent, d3ChartComponent ], }) export class OverviewComponent implements OnInit, OnDestroy { form: FormGroup; triadOfBalance$: Observable<string>; moralElement$: Observable<string>; combinedClass$ : Observable<string>; lastRouteSegment: string | null = null; private destroy$ = new Subject<void>(); triadOfBalance = [ { label: 'Order', value: 'order' }, { label: 'Chaos', value: 'chaos' }, { label: 'Equipoise', value: 'equipoise' }, ]; // Data for the chip filters moralTypeItems: ControlItem[] = [ { label: 'Order', value: 'order' }, { label: 'Chaos', value: 'chaos' }, { label: 'Equipoise', value: 'equipoise' } ]; elementItems: ControlItem[] = [ { label: 'Electric', value: 'electric' }, { label: 'Fire', value: 'fire' }, { label: 'Leaf', value: 'leaf' }, { label: 'Metal', value: 'metal' }, { label: 'Rock', value: 'rock' }, { label: 'Water', value: 'water' }, { label: 'Wind', value: 'wind' }, ]; relationshipItems: ControlItem[] = [ { label: 'Counters', value: 'counters' }, { label: 'PowerUp', value: 'powerup' }, ]; // Observables to hold the current filter values moralTypesFilter$: Observable<string[]>; elementsFilter$: Observable<string[]>; relationshipsFilter$: Observable<string[]>; constructor( private fb: FormBuilder, private store: Store<fromRoot.State>, private activatedRoute: ActivatedRoute, private router: Router, private cd: ChangeDetectorRef) {} ngOnInit(): void { this.form = this.fb.group({ triadOfBalance: ['order', { updateOn: 'change', validators: [ Validators.required ] }], // Initialize form controls, pre-selecting all chips by default moralTypes: [['order'], Validators.required], // Initialize with all selected elements: [['electric', 'fire', 'leaf', 'metal', 'rock', 'water', 'wind'], Validators.required], relationships: [['counters', 'powerup'], Validators.required], }); // 1. Get the initial last segment on component load this.extractLastSegment(this.router.url); // 2. Subscribe to router events to update when the route changes this.router.events.pipe( filter(event => event instanceof NavigationEnd), takeUntil(this.destroy$) // Automatically unsubscribe when the component is destroyed ).subscribe((event: NavigationEnd) => { this.extractLastSegment(event.urlAfterRedirects); this.cd.detectChanges(); }); // Initialize the filter observables by piping valueChanges with startWith this.moralTypesFilter$ = this.form.controls['moralTypes'].valueChanges.pipe( startWith(this.form.controls['moralTypes'].value), // Add tap to log and mark for check tap(value => { console.log('OverviewComponent: moralTypesFilter$ emitted:', value); this.cd.markForCheck(); // Request change detection for this component }), takeUntil(this.destroy$) ); this.elementsFilter$ = this.form.controls['elements'].valueChanges.pipe( startWith(this.form.controls['elements'].value), // Add tap to log and mark for check tap(value => { console.log('OverviewComponent: elementsFilter$ emitted:', value); this.cd.markForCheck(); // Request change detection for this component }), takeUntil(this.destroy$) ); this.relationshipsFilter$ = this.form.controls['relationships'].valueChanges.pipe( startWith(this.form.controls['relationships'].value), // Add tap to log and mark for check tap(value => { console.log('OverviewComponent: relationshipsFilter$ emitted:', value); this.cd.markForCheck(); // Request change detection for this component }), takeUntil(this.destroy$) ); // Select observables once this.triadOfBalance$ = this.store.pipe(select(fromUITheme.fromUITheme.getTriadOfBalance)); this.moralElement$ = this.store.pipe(select(fromUITheme.fromUITheme.getMoralElement)); // Combine observables for combinedClass$ this.combinedClass$ = combineLatest([this.triadOfBalance$, this.moralElement$]).pipe( map(([triadOfBalance, moralElement]) => `${triadOfBalance} ${moralElement}`) ); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } private extractLastSegment(url: string): void { const segments = url.split('/'); // Filter out any empty strings that might result from trailing slashes or multiple slashes const meaningfulSegments = segments.filter(segment => segment.length > 0); if (meaningfulSegments.length > 0) { this.lastRouteSegment = meaningfulSegments[meaningfulSegments.length - 1]; console.log('Last route segment:', this.lastRouteSegment); } else { this.lastRouteSegment = null; // Or an empty string, depending on your desired default } } // In OverviewComponent onMoralTypesChange(value: string[]): void { // console.log('Moral Types Chip Change:', value); // console.log('Form Control Value:', this.form.controls['moralTypes'].value); } } Here is the d3-chart.component.ts, its only a ts file: import { Component, OnInit, ElementRef, ViewChild, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import * as d3 from 'd3'; // --- DATA DEFINITIONS (These should ideally be in their own file) --- const ELEMENTS = ['electric', 'fire', 'leaf', 'metal', 'rock', 'water', 'wind']; const MORAL_TYPES = ['order', 'chaos', 'equipoise']; const createNodes = () => { const nodes: {id: string, element: string, moralType: string}[] = []; MORAL_TYPES.forEach(moralType => { ELEMENTS.forEach(element => { nodes.push({ id: `${element}-${moralType}`, element, moralType }); }); }); return nodes; }; const createLinks = () => { const links: {source: string, target: string, type: string}[] = []; const baseLinks = [ { source: 'Fire', target: 'Wind', type: 'powerup' }, { source: 'Wind', target: 'Water', type: 'powerup' }, { source: 'Water', target: 'Leaf', type: 'powerup' }, { source: 'Leaf', target: 'Rock', type: 'powerup' }, { source: 'Rock', target: 'Metal', type: 'powerup' }, { source: 'Metal', target: 'Electric', type: 'powerup' }, { source: 'Electric', target: 'Fire', type: 'powerup' }, { source: 'Electric', target: 'Water', type: 'counters' }, { source: 'Electric', target: 'Rock', type: 'counters' }, { source: 'Fire', target: 'Metal', type: 'counters' }, { source: 'Fire', target: 'Leaf', type: 'counters' }, { source: 'Leaf', target: 'Electric', type: 'counters' }, { source: 'Leaf', target: 'Wind', type: 'counters' }, { source: 'Metal', target: 'Wind', type: 'counters' }, { source: 'Metal', target: 'Leaf', type: 'counters' }, { source: 'Rock', target: 'Fire', type: 'counters' }, { source: 'Rock', target: 'Water', type: 'counters' }, { source: 'Water', target: 'Metal', type: 'counters' }, { source: 'Water', target: 'Fire', type: 'counters' }, { source: 'Wind', target: 'Electric', type: 'counters' }, { source: 'Wind', target: 'Rock', type: 'counters' }, ]; MORAL_TYPES.forEach(moralType => { baseLinks.forEach(baseLink => { links.push({ source: `${baseLink.source.toLowerCase()}-${moralType}`, target: `${baseLink.target.toLowerCase()}-${moralType}`, type: baseLink.type, // CORRECTED: Keep the original 'powers-up' value }); }); }); ELEMENTS.forEach(element => { links.push({ source: `${element}-chaos`, target: `${element}-order`, type: 'counters' }); links.push({ source: `${element}-equipoise`, target: `${element}-chaos`, type: 'counters' }); links.push({ source: `${element}-order`, target: `${element}-equipoise`, type: 'counters' }); }); return links; }; const bubblewockNodesData = createNodes(); const bubblewockLinksData = createLinks(); // --- TYPE DEFINITIONS --- export type BubbleNode = d3.SimulationNodeDatum & { id: string; element: string; moralType: string; }; export type BubbleLink = { source: string | BubbleNode; target: string | BubbleNode; type: string; }; @Component({ selector: 'app-d3-chart', template: `<div class="chart-container" #chartContainer></div>`, styles: [`.chart-container { width: 100%; height: 700px; background-color: transparent; } .node-container { cursor: grab; transition: transform 0.3s ease; } .node-container:active { cursor: grabbing; } .links line { stroke-opacity: 0.6; transition: stroke-opacity 0.3s ease; } .links line:hover { stroke-opacity: 1; } .node-label { font-size: 16px; paint-order: stroke; stroke: rgba(255,255,255,0.7); stroke-width: 3px; font-weight: bold; pointer-events: none; } `], changeDetection: ChangeDetectionStrategy.Default, standalone: true, imports: [CommonModule], }) export class d3ChartComponent implements OnInit, OnChanges { @ViewChild('chartContainer', { static: true }) private chartContainer!: ElementRef; @Input() moralTypes: string[]; //= ['order', 'chaos', 'equipoise']; @Input() elements: string[]; //['electric', 'fire', 'leaf', 'metal', 'rock', 'water', 'wind']; @Input() relationships: string[]; //['counters', 'powerup']; private svg: any; private simulation: d3.Simulation<BubbleNode, BubbleLink> | null = null; constructor() {} ngOnInit(): void { this.createSvg(); this.initializeSimulation(); this.updateChart(); } ngOnChanges(changes: SimpleChanges): void { console.groupCollapsed('d3ChartComponent: ngOnChanges triggered'); // Use group for cleaner console console.log('Changes object:', changes); if (changes['moralTypes']) { console.log(' moralTypes changed:', changes['moralTypes'].currentValue); } if (changes['elements']) { console.log(' elements changed:', changes['elements'].currentValue); } if (changes['relationships']) { console.log(' relationships changed:', changes['relationships'].currentValue); } console.groupEnd(); // Close the console group // Check if any of the filter inputs have actually changed and their values are not null/undefined if ( (changes['moralTypes'] && changes['moralTypes'].currentValue !== undefined) || (changes['elements'] && changes['elements'].currentValue !== undefined) || (changes['relationships'] && changes['relationships'].currentValue !== undefined) ) { if (this.simulation) { console.log('d3ChartComponent: Calling updateChart due to input changes.'); this.updateChart(); } } } private createSvg(): void { const element = this.chartContainer.nativeElement; this.svg = d3.select(element).append('svg') .attr('width', '100%') .attr('height', '100%'); this.svg.append('g').attr('class', 'links'); this.svg.append('g').attr('class', 'nodes'); this.svg.append('defs').selectAll('marker') .data(['counters', 'powerup']) .join('marker') .attr('id', (d: string) => `arrow-${d}`) .attr('viewBox', '0 -5 10 10') .attr('refX', 28) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('fill', (d: string) => d === 'counters' ? '#E74C3C' : '#2ECC71') .attr('d', 'M0,-5L10,0L0,5'); } private initializeSimulation(): void { this.simulation = d3.forceSimulation<BubbleNode>() .force('link', d3.forceLink<BubbleNode, BubbleLink>().id((d: BubbleNode) => d.id).strength(0.1).distance(100)) .force('charge', d3.forceManyBody().strength(-200)) // Increased repulsion for clarity .force('collision', d3.forceCollide().radius(30)) // Increased collision radius .on('tick', this.ticked.bind(this)); } private updateChart(): void { if (!this.simulation) return; const element = this.chartContainer.nativeElement; const width = element.clientWidth; const height = element.clientHeight; const radius = 22; // Define the properties for the arc *within* a data object const rippleArcData = { innerRadius: radius * 0.75, outerRadius: radius * 0.75, startAngle: -Math.PI / 2, endAngle: 0 }; const colorScale = d3.scaleOrdinal<string>(['#F8FF2C', '#FF5A2C', '#3BFF2C', '#B4B4B4', '#966432', '#2C8CFF', '#C8FFFA']); // --- FILTERING LOGIC --- const nodesData = bubblewockNodesData.filter(d => this.moralTypes?.includes(d.moralType) && this.elements?.includes(d.element) ); const visibleNodeIds = new Set(nodesData.map(d => d.id)); const linksData = bubblewockLinksData.filter(d => visibleNodeIds.has(d.source as string) && visibleNodeIds.has(d.target as string) && this.relationships?.includes(d.type) ); console.log('d3ChartComponent (updateChart): Nodes after filter:', nodesData.length, 'nodes'); console.log('d3ChartComponent (updateChart): Links after filter:', linksData.length, 'links'); // --- UPDATE SIMULATION --- this.simulation.nodes(nodesData); (this.simulation.force('link') as d3.ForceLink<BubbleNode, BubbleLink>).links(linksData); // Update forces based on filtered data for better layout this.simulation.force('center', d3.forceCenter(width / 2, height / 2)); this.simulation.force('radialOrder', d3.forceRadial(height / 2.5, width / 2, height / 2).strength((d: BubbleNode) => d.moralType === 'order' ? 0.7 : 0)); this.simulation.force('radialEquipoise', d3.forceRadial(height / 4, width / 2, height / 2).strength((d: BubbleNode) => d.moralType === 'equipoise' ? 0.6 : 0)); this.simulation.force('radialChaos', d3.forceRadial(height / 12, width / 2, height / 2).strength((d: BubbleNode) => d.moralType === 'chaos' ? 0.5 : 0)); // --- D3 GENERAL UPDATE PATTERN --- const linkElements = this.svg.select('.links') .selectAll('line') .data(linksData, (d: BubbleLink) => `${(d.source as BubbleNode).id}-${(d.target as BubbleNode).id}`) .join( (enter: any) => enter.append('line') .attr('stroke', (d: BubbleLink) => d.type === 'counters' ? '#E74C3C' : '#2ECC71') .attr('stroke-width', (d: BubbleLink) => d.type === 'counters' ? 1.5 : 3) .attr('marker-end', (d: BubbleLink) => `url(#arrow-${d.type})`), (update: any) => update, (exit: any) => exit.transition().duration(200).attr('stroke-opacity', 0).remove() ); const nodeElements = this.svg.select('.nodes') .selectAll('g.node-container') .data(nodesData, (d: BubbleNode) => d.id) .join( (enter: any) => { const nodeEnter = enter.append('g').attr('class', 'node-container').attr('transform', 'scale(0)'); nodeEnter.transition().duration(500).attr('transform', 'scale(1)'); nodeEnter.append('circle').attr('r', radius).attr('fill', (d: BubbleNode) => d.moralType === 'chaos' ? '#333' : '#fff'); nodeEnter.append('circle').attr('r', radius * 0.9).attr('fill', (d: BubbleNode) => colorScale(d.element)); //const rippleArc = d3.arc().innerRadius(radius * 0.75).outerRadius(radius * 0.75).startAngle(-Math.PI / 2).endAngle(0); const rippleArc = d3.arc().innerRadius(radius * 0.75).outerRadius(radius * 0.75).startAngle(-Math.PI / 2).endAngle(0); nodeEnter.append('path').attr('d', rippleArc(rippleArcData)).attr('stroke', '#fff').attr('stroke-opacity', 0.8).attr('stroke-width', 2.5); nodeEnter.append('path').attr('d', rippleArc(rippleArcData)).attr('stroke', '#fff').attr('stroke-opacity', 0.8).attr('stroke-width', 2.5); nodeEnter.append('text').text((d: BubbleNode) => d.element.charAt(0).toUpperCase()).attr('class', 'node-label').attr('text-anchor', 'middle').attr('dy', '.35em').attr('fill', (d: BubbleNode) => ['electric', 'wind'].includes(d.element) ? '#333' : '#fff'); nodeEnter.append('title').text((d: BubbleNode) => d.id); nodeEnter.call(this.dragHandler(this.simulation as d3.Simulation<BubbleNode, BubbleLink>)); return nodeEnter; }, (update: any) => update, (exit: any) => exit.transition().duration(200).attr('transform', 'scale(0)').remove() ); this.simulation.alpha(0.3).restart(); } private ticked(): void { this.svg.selectAll('.links line') .attr('x1', (d: any) => d.source.x) .attr('y1', (d: any) => d.source.y) .attr('x2', (d: any) => d.target.x) .attr('y2', (d: any) => d.target.y); this.svg.selectAll('.nodes g.node-container') .attr('transform', (d: any) => `translate(${d.x}, ${d.y})`); } private dragHandler(simulation: d3.Simulation<BubbleNode, BubbleLink>) { return d3.drag<SVGGElement, BubbleNode>() .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }); } } I know that a lot is going on, but I am truly baffled by why my chart wont update. Please if anyone could try and figure out the issue, I would very much appreciate it. Continue reading...