Initial commit: data-viz-elements-ui library
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
171
src/components/viz-histogram/viz-histogram.component.ts
Normal file
171
src/components/viz-histogram/viz-histogram.component.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user