D3 wont update chart when Inputs change values from form

Discussão em 'Angular' iniciado por nuccio, Junho 20, 2025.

  1. nuccio

    nuccio Guest

    [​IMG]

    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...

Compartilhe esta Página