import { Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy, inject, input, output, viewChild, effect, } from '@angular/core'; import { select, Selection } from 'd3-selection'; import { scaleLinear } from 'd3-scale'; import { axisBottom, axisLeft } from 'd3-axis'; import { bin, max, extent } from 'd3-array'; import 'd3-transition'; import { VIZ_CONFIG } from '../../providers/viz-config.provider'; import { VizThemeService } from '../../services/viz-theme.service'; import { VizResizeService } from '../../services/viz-resize.service'; import { VizTooltipService } from '../../services/viz-tooltip.service'; import type { ChartMargin, AxisConfig } from '../../types/config.types'; import type { ChartClickEvent } from '../../types/event.types'; @Component({ selector: 'viz-histogram', standalone: true, templateUrl: './viz-histogram.component.html', styleUrl: './viz-histogram.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class VizHistogramComponent implements OnDestroy { readonly values = input.required(); readonly width = input('auto'); readonly height = input(300); readonly margin = input({ top: 20, right: 20, bottom: 40, left: 50 }); readonly xAxis = input({}); readonly yAxis = input({ gridLines: true }); readonly animate = input(undefined); readonly bins = input(20); readonly density = input(false); readonly color = input(undefined); readonly binClick = output>(); private readonly chartRef = viewChild.required>('chart'); private readonly ngZone = inject(NgZone); private readonly config = inject(VIZ_CONFIG); private readonly themeService = inject(VizThemeService); private readonly resizeService = inject(VizResizeService); private readonly tooltipService = inject(VizTooltipService); private svg: Selection | null = null; private resizeCleanup: (() => void) | null = null; constructor() { effect(() => { const _ = this.values(); this.ngZone.runOutsideAngular(() => { if (!this.svg) { this.createChart(); this.setupResize(); } else { this.updateChart(); } }); }); } ngOnDestroy(): void { this.resizeCleanup?.(); this.svg?.remove(); } private getWidth(): number { const w = this.width(); return w === 'auto' ? (this.chartRef().nativeElement.clientWidth || 600) : w; } private createChart(): void { const el = this.chartRef().nativeElement; this.svg = select(el).append('svg').attr('class', 'viz-histogram-svg'); this.svg.append('g').attr('class', 'x-axis'); this.svg.append('g').attr('class', 'y-axis'); this.svg.append('g').attr('class', 'bars'); this.updateChart(); } private updateChart(): void { if (!this.svg) return; const values = this.values(); const w = this.getWidth(); const h = this.height(); const m = this.margin(); const innerW = w - m.left - m.right; const innerH = h - m.top - m.bottom; const shouldAnimate = this.animate() ?? this.config.animate; const duration = shouldAnimate ? this.config.animationDuration : 0; const barColor = this.color() ?? this.themeService.getColor(0); this.svg.attr('width', w).attr('height', h); const [minVal, maxVal] = extent(values) as [number, number]; const xScale = scaleLinear().domain([minVal, maxVal]).range([0, innerW]).nice(); const histogram = bin().domain(xScale.domain() as [number, number]).thresholds(this.bins()); const binsData = histogram(values); const yScale = scaleLinear() .domain([0, max(binsData, d => d.length) ?? 0]) .range([innerH, 0]) .nice(); this.svg.select('.x-axis') .attr('transform', `translate(${m.left},${m.top + innerH})`) .call(axisBottom(xScale)); const yAxisG = this.svg.select('.y-axis') .attr('transform', `translate(${m.left},${m.top})`) .call(axisLeft(yScale).ticks(this.yAxis().tickCount ?? 5)); if (this.yAxis().gridLines) { yAxisG.selectAll('.grid-line').remove(); yAxisG.selectAll('.tick line') .clone() .attr('x2', innerW) .attr('stroke', 'var(--viz-grid-color, #f3f4f6)') .attr('stroke-dasharray', '2,2') .attr('class', 'grid-line'); } const barsG = this.svg.select('.bars').attr('transform', `translate(${m.left},${m.top})`); const bars = barsG.selectAll('rect') .data(binsData); bars.exit().transition().duration(duration).attr('height', 0).attr('y', innerH).remove(); bars.enter() .append('rect') .attr('y', innerH) .attr('height', 0) .attr('rx', 1) .attr('fill', barColor) .merge(bars) .on('mouseenter', (event: MouseEvent, d) => { if (this.config.tooltips) { this.tooltipService.show(event.clientX, event.clientY, `${d.x0?.toFixed(1)} – ${d.x1?.toFixed(1)}: ${d.length}`); } }) .on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY)) .on('mouseleave', () => this.tooltipService.hide()) .on('click', (event: MouseEvent, d) => { this.ngZone.run(() => this.binClick.emit({ data: { x0: d.x0 ?? 0, x1: d.x1 ?? 0, count: d.length }, index: binsData.indexOf(d), event, })); }) .transition() .duration(duration) .attr('x', d => xScale(d.x0 ?? 0)) .attr('width', d => Math.max(0, xScale(d.x1 ?? 0) - xScale(d.x0 ?? 0) - 1)) .attr('y', d => yScale(d.length)) .attr('height', d => innerH - yScale(d.length)); } private setupResize(): void { if (this.width() === 'auto' && this.config.responsive) { this.resizeCleanup = this.resizeService.observe( this.chartRef().nativeElement, () => this.updateChart(), ); } } }