172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
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(),
|
||
);
|
||
}
|
||
}
|
||
}
|