Initial commit: data-viz-elements-ui library

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-02-13 22:06:35 +10:00
commit 0ce172bfc1
98 changed files with 8832 additions and 0 deletions

View 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(),
);
}
}
}