Files
data-viz-elements-ui/src/components/viz-histogram/viz-histogram.component.ts
Giuliano Silvestro 0ce172bfc1 Initial commit: data-viz-elements-ui library
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:06:35 +10:00

172 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number[]>();
readonly width = input<number | 'auto'>('auto');
readonly height = input<number>(300);
readonly margin = input<ChartMargin>({ top: 20, right: 20, bottom: 40, left: 50 });
readonly xAxis = input<AxisConfig>({});
readonly yAxis = input<AxisConfig>({ gridLines: true });
readonly animate = input<boolean | undefined>(undefined);
readonly bins = input<number>(20);
readonly density = input(false);
readonly color = input<string | undefined>(undefined);
readonly binClick = output<ChartClickEvent<{ x0: number; x1: number; count: number }>>();
private readonly chartRef = viewChild.required<ElementRef<HTMLDivElement>>('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<SVGSVGElement, unknown, null, undefined> | 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<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.call(axisBottom(xScale));
const yAxisG = this.svg.select<SVGGElement>('.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<SVGRectElement, (typeof binsData)[0]>('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)}: <strong>${d.length}</strong>`);
}
})
.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(),
);
}
}
}