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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
.angular/
*.tgz

12
build-for-dev.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# Build the library
npm run build
# Link it locally for development
cd dist
npm link
echo "✓ Library built and linked successfully"
echo "Run 'npm link @sda/data-viz-elements-ui' in your consuming app"

21
ng-package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/index.ts"
},
"dest": "dist",
"deleteDestPath": true,
"allowedNonPeerDependencies": [
"d3-array",
"d3-axis",
"d3-color",
"d3-format",
"d3-hierarchy",
"d3-interpolate",
"d3-scale",
"d3-selection",
"d3-shape",
"d3-time-format",
"d3-transition"
]
}

4083
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

67
package.json Normal file
View File

@@ -0,0 +1,67 @@
{
"name": "@sda/data-viz-elements-ui",
"version": "0.1.0",
"description": "Angular components for data visualization, charts, and dashboard widgets powered by D3.js",
"keywords": [
"angular",
"data-visualization",
"charts",
"d3",
"dashboard",
"components",
"ui"
],
"repository": {
"type": "git",
"url": "https://git.sky-ai.com/ui-core-design/data-viz-elements-ui.git"
},
"license": "MIT",
"sideEffects": false,
"scripts": {
"build": "ng-packagr -p ng-package.json",
"build:dev": "./build-for-dev.sh"
},
"peerDependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
"@sda/base-ui": "*"
},
"peerDependenciesMeta": {
"@sda/base-ui": {
"optional": true
}
},
"dependencies": {
"d3-array": "^3.2.0",
"d3-axis": "^3.0.0",
"d3-color": "^3.1.0",
"d3-format": "^3.1.0",
"d3-hierarchy": "^3.1.0",
"d3-interpolate": "^3.0.0",
"d3-scale": "^4.0.0",
"d3-selection": "^3.0.0",
"d3-shape": "^3.2.0",
"d3-time-format": "^4.1.0",
"d3-transition": "^3.0.0"
},
"devDependencies": {
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/compiler-cli": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.2.18",
"@types/d3-array": "^3.2.0",
"@types/d3-axis": "^3.0.0",
"@types/d3-color": "^3.1.0",
"@types/d3-format": "^3.0.0",
"@types/d3-hierarchy": "^3.1.0",
"@types/d3-interpolate": "^3.0.0",
"@types/d3-scale": "^4.0.0",
"@types/d3-selection": "^3.0.0",
"@types/d3-shape": "^3.1.0",
"@types/d3-time-format": "^4.0.0",
"@types/d3-transition": "^3.0.0",
"ng-packagr": "^19.1.0",
"typescript": "~5.7.2"
}
}

18
src/components/index.ts Normal file
View File

@@ -0,0 +1,18 @@
export * from './viz-bar-chart';
export * from './viz-line-chart';
export * from './viz-area-chart';
export * from './viz-pie-chart';
export * from './viz-scatter-chart';
export * from './viz-legend';
export * from './viz-histogram';
export * from './viz-box-plot';
export * from './viz-heatmap';
export * from './viz-treemap';
export * from './viz-gauge';
export * from './viz-sparkline';
export * from './viz-time-series';
export * from './viz-stat-card';
export * from './viz-progress-ring';
export * from './viz-progress-bar';
export * from './viz-trend-indicator';
export * from './viz-data-table';

View File

@@ -0,0 +1 @@
export * from './viz-area-chart.component';

View File

@@ -0,0 +1,9 @@
<div class="viz-area-chart" [class.viz-area-chart--legend-left]="legend().visible && legend().position === 'left'" [class.viz-area-chart--legend-right]="legend().visible && legend().position === 'right'">
@if (legend().visible && (legend().position === 'top' || legend().position === 'left')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
<div #chart></div>
@if (legend().visible && (legend().position === 'bottom' || legend().position === 'right')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
</div>

View File

@@ -0,0 +1,46 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-area-chart {
display: flex;
flex-direction: column;
width: 100%;
&--legend-left,
&--legend-right {
flex-direction: row;
}
> div:not(.viz-legend) {
flex: 1;
min-width: 0;
}
::ng-deep {
.viz-area-chart-svg {
display: block;
overflow: visible;
}
.domain {
stroke: var(--viz-axis-color);
}
.tick line {
stroke: var(--viz-tick-color);
}
.tick text {
fill: var(--viz-text-muted);
font-size: var(--viz-font-size-xs);
}
.grid-line {
stroke: var(--viz-grid-color);
stroke-dasharray: 2, 2;
}
}
}

View File

@@ -0,0 +1,223 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect, computed, signal,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleLinear, scaleTime } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { area, line, curveMonotoneX } from 'd3-shape';
import { extent, max } 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 { CartesianDataPoint, ChartSeries } from '../../types/chart.types';
import type { ChartMargin, AxisConfig, LegendConfig } from '../../types/config.types';
import type { ChartHoverEvent } from '../../types/event.types';
import { withOpacity } from '../../utils/color.utils';
import { VizLegendComponent, LegendItem } from '../viz-legend/viz-legend.component';
@Component({
selector: 'viz-area-chart',
standalone: true,
imports: [VizLegendComponent],
templateUrl: './viz-area-chart.component.html',
styleUrl: './viz-area-chart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizAreaChartComponent implements OnDestroy {
readonly series = input.required<ChartSeries[]>();
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 legend = input<LegendConfig>({ visible: true, position: 'top' });
readonly animate = input<boolean | undefined>(undefined);
readonly stacked = input(false);
readonly gradient = input(true);
readonly areaHover = output<ChartHoverEvent<CartesianDataPoint>>();
readonly hiddenItems = signal<Set<string>>(new Set());
readonly legendItems = computed(() =>
this.series().map((s, i) => ({
label: s.name,
color: s.color ?? this.themeService.getColor(i),
active: !this.hiddenItems().has(s.name),
}))
);
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.series();
const __ = this.hiddenItems();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
this.setupResize();
} else {
this.updateChart();
}
});
});
}
onLegendClick(event: { item: LegendItem; index: number }): void {
const hidden = new Set(this.hiddenItems());
if (hidden.has(event.item.label)) {
hidden.delete(event.item.label);
} else {
hidden.add(event.item.label);
}
this.hiddenItems.set(hidden);
}
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-area-chart-svg');
this.svg.append('defs');
this.svg.append('g').attr('class', 'x-axis');
this.svg.append('g').attr('class', 'y-axis');
this.svg.append('g').attr('class', 'areas');
this.svg.append('g').attr('class', 'lines');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const allSeries = this.series().filter(s => s.visible !== false && !this.hiddenItems().has(s.name));
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 yAxisConfig = this.yAxis();
this.svg.attr('width', w).attr('height', h);
const allPoints: CartesianDataPoint[] = [];
for (const s of allSeries) { for (const d of s.data) { allPoints.push(d); } }
const isTime = allPoints.length > 0 && allPoints[0].x instanceof Date;
const xScale = isTime
? scaleTime().domain(extent(allPoints, (d: CartesianDataPoint) => d.x as Date) as [Date, Date]).range([0, innerW])
: scaleLinear().domain(extent(allPoints, (d: CartesianDataPoint) => d.x as number) as [number, number]).range([0, innerW]);
const yMax = yAxisConfig.max ?? (max(allPoints, (d: CartesianDataPoint) => d.y) ?? 0);
const yScale = scaleLinear()
.domain([yAxisConfig.min ?? 0, yMax])
.range([innerH, 0])
.nice();
// Axes
this.svg.select<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.call(axisBottom(xScale as any));
const yAxisG = this.svg.select<SVGGElement>('.y-axis')
.attr('transform', `translate(${m.left},${m.top})`)
.call(axisLeft(yScale).ticks(yAxisConfig.tickCount ?? 5));
if (yAxisConfig.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');
}
// Gradients
if (this.gradient()) {
const defs = this.svg.select('defs');
defs.selectAll('*').remove();
allSeries.forEach((s, i) => {
const color = s.color ?? this.themeService.getColor(i);
const grad = defs.append('linearGradient')
.attr('id', `area-gradient-${i}`)
.attr('x1', '0%').attr('y1', '0%')
.attr('x2', '0%').attr('y2', '100%');
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.4);
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.05);
});
}
// Area generator
const areaGen = area<CartesianDataPoint>()
.x(d => (xScale as any)(d.x))
.y0(innerH)
.y1(d => yScale(d.y))
.curve(curveMonotoneX);
const lineGen = line<CartesianDataPoint>()
.x(d => (xScale as any)(d.x))
.y(d => yScale(d.y))
.curve(curveMonotoneX);
// Areas
const areasG = this.svg.select('.areas').attr('transform', `translate(${m.left},${m.top})`);
const areas = areasG.selectAll<SVGPathElement, ChartSeries>('path').data(allSeries, (d: ChartSeries) => d.name);
areas.exit().remove();
areas.enter().append('path')
.merge(areas)
.transition().duration(duration)
.attr('d', d => areaGen(d.data) ?? '')
.attr('fill', (_, i) => this.gradient() ? `url(#area-gradient-${i})` : withOpacity(this.themeService.getColor(i), 0.2));
// Lines
const linesG = this.svg.select('.lines').attr('transform', `translate(${m.left},${m.top})`);
const lines = linesG.selectAll<SVGPathElement, ChartSeries>('path').data(allSeries, (d: ChartSeries) => d.name);
lines.exit().remove();
lines.enter().append('path')
.attr('fill', 'none')
.attr('stroke-width', 2)
.merge(lines)
.transition().duration(duration)
.attr('d', d => lineGen(d.data) ?? '')
.attr('stroke', (d, i) => d.color ?? this.themeService.getColor(i));
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-bar-chart.component';

View File

@@ -0,0 +1,9 @@
<div class="viz-bar-chart" [class.viz-bar-chart--legend-left]="legend().visible && legend().position === 'left'" [class.viz-bar-chart--legend-right]="legend().visible && legend().position === 'right'">
@if (legend().visible && (legend().position === 'top' || legend().position === 'left')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
<div #chart></div>
@if (legend().visible && (legend().position === 'bottom' || legend().position === 'right')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
</div>

View File

@@ -0,0 +1,55 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-bar-chart {
display: flex;
flex-direction: column;
width: 100%;
&--legend-left,
&--legend-right {
flex-direction: row;
}
> div:not(.viz-legend) {
flex: 1;
min-width: 0;
}
::ng-deep {
.viz-bar-chart-svg {
display: block;
overflow: visible;
}
.domain {
stroke: var(--viz-axis-color);
}
.tick line {
stroke: var(--viz-tick-color);
}
.tick text {
fill: var(--viz-text-muted);
font-size: var(--viz-font-size-xs);
}
.grid-line {
stroke: var(--viz-grid-color);
stroke-dasharray: 2, 2;
}
rect {
cursor: pointer;
transition: opacity var(--viz-transition);
&:hover {
opacity: 0.8;
}
}
}
}

View File

@@ -0,0 +1,279 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect, computed, signal,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleBand, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { max } 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 { ChartDataPoint } from '../../types/chart.types';
import type { ChartMargin, AxisConfig, LegendConfig } from '../../types/config.types';
import type { ChartClickEvent, ChartHoverEvent } from '../../types/event.types';
import { VizLegendComponent, LegendItem } from '../viz-legend/viz-legend.component';
@Component({
selector: 'viz-bar-chart',
standalone: true,
imports: [VizLegendComponent],
templateUrl: './viz-bar-chart.component.html',
styleUrl: './viz-bar-chart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizBarChartComponent implements OnDestroy {
readonly data = input.required<ChartDataPoint[]>();
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 legend = input<LegendConfig>({ visible: false, position: 'top' });
readonly animate = input<boolean | undefined>(undefined);
readonly orientation = input<'vertical' | 'horizontal'>('vertical');
readonly stacked = input(false);
readonly grouped = input(false);
readonly barClick = output<ChartClickEvent<ChartDataPoint>>();
readonly barHover = output<ChartHoverEvent<ChartDataPoint>>();
readonly hiddenItems = signal<Set<string>>(new Set());
readonly legendItems = computed(() =>
this.data().map((d, i) => ({
label: d.label,
color: d.color ?? this.themeService.getColor(i),
active: !this.hiddenItems().has(d.label),
}))
);
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.data();
const __ = this.hiddenItems();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
this.setupResize();
} else {
this.updateChart();
}
});
});
}
onLegendClick(event: { item: LegendItem; index: number }): void {
const hidden = new Set(this.hiddenItems());
if (hidden.has(event.item.label)) {
hidden.delete(event.item.label);
} else {
hidden.add(event.item.label);
}
this.hiddenItems.set(hidden);
}
ngOnDestroy(): void {
this.resizeCleanup?.();
this.svg?.remove();
}
private getWidth(): number {
const w = this.width();
if (w === 'auto') {
return this.chartRef().nativeElement.clientWidth || 600;
}
return w;
}
private createChart(): void {
const el = this.chartRef().nativeElement;
const w = this.getWidth();
const h = this.height();
this.svg = select(el)
.append('svg')
.attr('width', w)
.attr('height', h)
.attr('class', 'viz-bar-chart-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 data = this.data().filter(d => !this.hiddenItems().has(d.label));
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 yAxisConfig = this.yAxis();
const isVertical = this.orientation() === 'vertical';
this.svg.attr('width', w).attr('height', h);
if (isVertical) {
const x = scaleBand<string>()
.domain(data.map(d => d.label))
.range([0, innerW])
.padding(0.2);
const y = scaleLinear()
.domain([yAxisConfig.min ?? 0, yAxisConfig.max ?? (max(data, d => d.value) ?? 0)])
.range([innerH, 0])
.nice();
// X axis
this.svg.select<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.call(axisBottom(x));
// Y axis
const yAxisG = this.svg.select<SVGGElement>('.y-axis')
.attr('transform', `translate(${m.left},${m.top})`)
.call(axisLeft(y).ticks(yAxisConfig.tickCount ?? 5));
// Grid lines
if (yAxisConfig.gridLines) {
yAxisG.selectAll('.tick line')
.clone()
.attr('x2', innerW)
.attr('stroke', 'var(--viz-grid-color, #f3f4f6)')
.attr('stroke-dasharray', '2,2')
.attr('class', 'grid-line');
}
// Bars
const barsG = this.svg.select('.bars')
.attr('transform', `translate(${m.left},${m.top})`);
const bars = barsG.selectAll<SVGRectElement, ChartDataPoint>('rect')
.data(data, (d: ChartDataPoint) => d.label);
bars.exit().transition().duration(duration).attr('height', 0).attr('y', innerH).remove();
const enter = bars.enter()
.append('rect')
.attr('x', d => x(d.label) ?? 0)
.attr('width', x.bandwidth())
.attr('y', innerH)
.attr('height', 0)
.attr('rx', 2)
.attr('fill', (d, i) => d.color ?? this.themeService.getColor(i));
enter.merge(bars)
.on('click', (event: MouseEvent, d: ChartDataPoint) => {
this.ngZone.run(() => this.barClick.emit({ data: d, index: data.indexOf(d), event }));
})
.on('mouseenter', (event: MouseEvent, d: ChartDataPoint) => {
if (this.config.tooltips) {
this.tooltipService.show(event.clientX, event.clientY, `<strong>${d.label}</strong>: ${d.value}`);
}
this.ngZone.run(() => this.barHover.emit({ data: d, index: data.indexOf(d), event }));
})
.on('mousemove', (event: MouseEvent) => {
this.tooltipService.update(event.clientX, event.clientY);
})
.on('mouseleave', (event: MouseEvent) => {
this.tooltipService.hide();
this.ngZone.run(() => this.barHover.emit({ data: null, index: -1, event }));
})
.transition()
.duration(duration)
.attr('x', d => x(d.label) ?? 0)
.attr('width', x.bandwidth())
.attr('y', d => y(d.value))
.attr('height', d => innerH - y(d.value))
.attr('fill', (d, i) => d.color ?? this.themeService.getColor(i));
} else {
// Horizontal bars
const y = scaleBand<string>()
.domain(data.map(d => d.label))
.range([0, innerH])
.padding(0.2);
const x = scaleLinear()
.domain([0, yAxisConfig.max ?? (max(data, d => d.value) ?? 0)])
.range([0, innerW])
.nice();
this.svg.select<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.call(axisBottom(x).ticks(yAxisConfig.tickCount ?? 5));
this.svg.select<SVGGElement>('.y-axis')
.attr('transform', `translate(${m.left},${m.top})`)
.call(axisLeft(y));
const barsG = this.svg.select('.bars')
.attr('transform', `translate(${m.left},${m.top})`);
const bars = barsG.selectAll<SVGRectElement, ChartDataPoint>('rect')
.data(data, (d: ChartDataPoint) => d.label);
bars.exit().transition().duration(duration).attr('width', 0).remove();
const enter = bars.enter()
.append('rect')
.attr('x', 0)
.attr('y', d => y(d.label) ?? 0)
.attr('width', 0)
.attr('height', y.bandwidth())
.attr('rx', 2)
.attr('fill', (d, i) => d.color ?? this.themeService.getColor(i));
enter.merge(bars)
.on('click', (event: MouseEvent, d: ChartDataPoint) => {
this.ngZone.run(() => this.barClick.emit({ data: d, index: data.indexOf(d), event }));
})
.on('mouseenter', (event: MouseEvent, d: ChartDataPoint) => {
if (this.config.tooltips) {
this.tooltipService.show(event.clientX, event.clientY, `<strong>${d.label}</strong>: ${d.value}`);
}
this.ngZone.run(() => this.barHover.emit({ data: d, index: data.indexOf(d), event }));
})
.on('mousemove', (event: MouseEvent) => {
this.tooltipService.update(event.clientX, event.clientY);
})
.on('mouseleave', (event: MouseEvent) => {
this.tooltipService.hide();
this.ngZone.run(() => this.barHover.emit({ data: null, index: -1, event }));
})
.transition()
.duration(duration)
.attr('y', d => y(d.label) ?? 0)
.attr('height', y.bandwidth())
.attr('width', d => x(d.value));
}
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-box-plot.component';

View File

@@ -0,0 +1 @@
<div class="viz-box-plot" #chart></div>

View File

@@ -0,0 +1,17 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-box-plot {
width: 100%;
::ng-deep {
.viz-box-plot-svg { display: block; overflow: visible; }
.domain { stroke: var(--viz-axis-color); }
.tick line { stroke: var(--viz-tick-color); }
.tick text { fill: var(--viz-text-muted); font-size: var(--viz-font-size-xs); }
.grid-line { stroke: var(--viz-grid-color); stroke-dasharray: 2, 2; }
}
}

View File

@@ -0,0 +1,217 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleBand, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { min, max } 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 { BoxPlotData } from '../../types/chart.types';
import type { ChartMargin, AxisConfig } from '../../types/config.types';
import type { ChartHoverEvent } from '../../types/event.types';
@Component({
selector: 'viz-box-plot',
standalone: true,
templateUrl: './viz-box-plot.component.html',
styleUrl: './viz-box-plot.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizBoxPlotComponent implements OnDestroy {
readonly data = input.required<BoxPlotData[]>();
readonly width = input<number | 'auto'>('auto');
readonly height = input<number>(300);
readonly margin = input<ChartMargin>({ top: 20, right: 20, bottom: 40, left: 50 });
readonly yAxis = input<AxisConfig>({ gridLines: true });
readonly animate = input<boolean | undefined>(undefined);
readonly showOutliers = input(true);
readonly orientation = input<'vertical' | 'horizontal'>('vertical');
readonly boxHover = output<ChartHoverEvent<BoxPlotData>>();
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.data();
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-box-plot-svg');
this.svg.append('g').attr('class', 'x-axis');
this.svg.append('g').attr('class', 'y-axis');
this.svg.append('g').attr('class', 'boxes');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const data = this.data();
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;
this.svg.attr('width', w).attr('height', h);
const xScale = scaleBand<string>()
.domain(data.map(d => d.label))
.range([0, innerW])
.padding(0.3);
const allValues: number[] = [];
for (const d of data) { allValues.push(d.min, d.max, ...(d.outliers ?? [])); }
const yScale = scaleLinear()
.domain([min(allValues) ?? 0, max(allValues) ?? 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));
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 boxesG = this.svg.select('.boxes').attr('transform', `translate(${m.left},${m.top})`);
boxesG.selectAll('*').remove();
const bandwidth = xScale.bandwidth();
const center = bandwidth / 2;
data.forEach((d, i) => {
const x = xScale(d.label) ?? 0;
const g = boxesG.append('g').attr('transform', `translate(${x},0)`);
const color = this.themeService.getColor(i);
// Vertical line min-max (whisker)
g.append('line')
.attr('x1', center).attr('x2', center)
.attr('y1', yScale(d.min)).attr('y2', yScale(d.max))
.attr('stroke', 'var(--viz-axis-color, #6b7280)')
.attr('stroke-width', 1);
// Min whisker cap
g.append('line')
.attr('x1', center - bandwidth * 0.2).attr('x2', center + bandwidth * 0.2)
.attr('y1', yScale(d.min)).attr('y2', yScale(d.min))
.attr('stroke', 'var(--viz-axis-color, #6b7280)')
.attr('stroke-width', 1);
// Max whisker cap
g.append('line')
.attr('x1', center - bandwidth * 0.2).attr('x2', center + bandwidth * 0.2)
.attr('y1', yScale(d.max)).attr('y2', yScale(d.max))
.attr('stroke', 'var(--viz-axis-color, #6b7280)')
.attr('stroke-width', 1);
// Box (Q1 to Q3)
g.append('rect')
.attr('x', 0)
.attr('y', yScale(d.q3))
.attr('width', bandwidth)
.attr('height', yScale(d.q1) - yScale(d.q3))
.attr('fill', color)
.attr('opacity', 0.6)
.attr('stroke', color)
.attr('stroke-width', 1.5)
.attr('rx', 2);
// Median line
g.append('line')
.attr('x1', 0).attr('x2', bandwidth)
.attr('y1', yScale(d.median)).attr('y2', yScale(d.median))
.attr('stroke', 'var(--viz-text, #111827)')
.attr('stroke-width', 2);
// Outliers
if (this.showOutliers() && d.outliers) {
d.outliers.forEach(o => {
g.append('circle')
.attr('cx', center)
.attr('cy', yScale(o))
.attr('r', 3)
.attr('fill', 'none')
.attr('stroke', color)
.attr('stroke-width', 1.5);
});
}
// Hover area
g.append('rect')
.attr('x', 0).attr('y', 0)
.attr('width', bandwidth).attr('height', innerH)
.attr('fill', 'transparent')
.on('mouseenter', (event: MouseEvent) => {
if (this.config.tooltips) {
this.tooltipService.show(event.clientX, event.clientY,
`<strong>${d.label}</strong><br>Min: ${d.min} | Q1: ${d.q1}<br>Median: ${d.median}<br>Q3: ${d.q3} | Max: ${d.max}`);
}
this.ngZone.run(() => this.boxHover.emit({ data: d, index: i, event }));
})
.on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY))
.on('mouseleave', (event: MouseEvent) => {
this.tooltipService.hide();
this.ngZone.run(() => this.boxHover.emit({ data: null, index: -1, event }));
});
});
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-data-table.component';

View File

@@ -0,0 +1,90 @@
<div class="viz-data-table">
@if (filterable()) {
<div class="viz-data-table__filter">
<input
type="text"
class="viz-data-table__filter-input"
placeholder="Filter..."
[value]="filterText()"
(input)="onFilterInput($event)"
/>
</div>
}
<div class="viz-data-table__wrapper">
<table
class="viz-data-table__table"
[class.viz-data-table__table--striped]="striped()"
[class.viz-data-table__table--hoverable]="hoverable()"
>
<thead>
<tr>
@for (col of columns(); track col.key) {
<th
[style.width]="col.width ?? 'auto'"
[style.text-align]="col.align ?? 'left'"
[class.viz-data-table__th--sortable]="sortable() && col.sortable !== false"
(click)="onSort(col)"
>
<span class="viz-data-table__th-content">
{{ col.label }}
@if (sortable() && col.sortable !== false) {
<span class="viz-data-table__sort-icon">{{ getSortIcon(col) }}</span>
}
</span>
</th>
}
</tr>
</thead>
<tbody>
@for (row of pagedData(); track $index; let i = $index) {
<tr (click)="onRowClick(row, i)">
@for (col of columns(); track col.key) {
<td [style.text-align]="col.align ?? 'left'">
{{ getCellValue(row, col) }}
</td>
}
</tr>
}
@if (pagedData().length === 0) {
<tr>
<td [attr.colspan]="columns().length" class="viz-data-table__empty">
No data available
</td>
</tr>
}
</tbody>
</table>
</div>
@if (paginated() && totalPages() > 1) {
<div class="viz-data-table__pagination">
<button
class="viz-data-table__page-btn"
[disabled]="currentPage() === 0"
(click)="onPageChange(currentPage() - 1)"
type="button"
>
&laquo;
</button>
@for (page of pageNumbers(); track page) {
<button
class="viz-data-table__page-btn"
[class.viz-data-table__page-btn--active]="page === currentPage()"
(click)="onPageChange(page)"
type="button"
>
{{ page + 1 }}
</button>
}
<button
class="viz-data-table__page-btn"
[disabled]="currentPage() === totalPages() - 1"
(click)="onPageChange(currentPage() + 1)"
type="button"
>
&raquo;
</button>
</div>
}
</div>

View File

@@ -0,0 +1,149 @@
:host {
display: block;
width: 100%;
}
.viz-data-table {
&__filter {
margin-bottom: var(--viz-spacing-md);
}
&__filter-input {
width: 100%;
max-width: 300px;
padding: var(--viz-spacing-sm) var(--viz-spacing-md);
border: 1px solid var(--viz-border);
border-radius: var(--viz-radius-md);
background: var(--viz-bg);
color: var(--viz-text);
font-size: var(--viz-font-size-sm);
outline: none;
transition: border-color var(--viz-transition);
&:focus {
border-color: var(--viz-color-1, #3b82f6);
}
&::placeholder {
color: var(--viz-text-muted);
}
}
&__wrapper {
overflow-x: auto;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: var(--viz-font-size-sm);
th,
td {
padding: var(--viz-spacing-sm) var(--viz-spacing-md);
border-bottom: 1px solid var(--viz-border);
}
th {
font-weight: 600;
color: var(--viz-text-muted);
text-transform: uppercase;
font-size: var(--viz-font-size-xs);
letter-spacing: 0.05em;
background: var(--viz-bg);
position: sticky;
top: 0;
user-select: none;
}
td {
color: var(--viz-text);
}
&--striped {
tbody tr:nth-child(even) {
background: var(--viz-grid-color);
}
}
&--hoverable {
tbody tr {
cursor: pointer;
transition: background-color var(--viz-transition);
&:hover {
background: var(--viz-grid-color);
}
}
}
}
&__th--sortable {
cursor: pointer;
&:hover {
color: var(--viz-text);
}
}
&__th-content {
display: inline-flex;
align-items: center;
gap: var(--viz-spacing-xs);
}
&__sort-icon {
font-size: 0.625rem;
opacity: 0.5;
}
&__empty {
text-align: center;
color: var(--viz-text-muted);
padding: var(--viz-spacing-lg) !important;
}
&__pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--viz-spacing-xs);
margin-top: var(--viz-spacing-md);
padding: var(--viz-spacing-sm) 0;
}
&__page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 var(--viz-spacing-sm);
border: 1px solid var(--viz-border);
border-radius: var(--viz-radius-sm);
background: var(--viz-bg);
color: var(--viz-text);
font-size: var(--viz-font-size-sm);
cursor: pointer;
transition: all var(--viz-transition);
&:hover:not(:disabled) {
background: var(--viz-grid-color);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&--active {
background: var(--viz-color-1, #3b82f6);
color: #fff;
border-color: var(--viz-color-1, #3b82f6);
&:hover {
background: var(--viz-color-1, #3b82f6);
}
}
}
}

View File

@@ -0,0 +1,122 @@
import {
Component, ChangeDetectionStrategy, input, output, computed, signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import type { TableColumn, TableSort } from '../../types/chart.types';
import type { TablePageEvent, TableSortEvent } from '../../types/event.types';
@Component({
selector: 'viz-data-table',
standalone: true,
imports: [CommonModule],
templateUrl: './viz-data-table.component.html',
styleUrl: './viz-data-table.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizDataTableComponent<T = Record<string, unknown>> {
readonly data = input.required<T[]>();
readonly columns = input.required<TableColumn<T>[]>();
readonly sortable = input(true);
readonly filterable = input(false);
readonly paginated = input(false);
readonly pageSize = input(10);
readonly striped = input(true);
readonly hoverable = input(true);
readonly sortChange = output<TableSortEvent>();
readonly pageChange = output<TablePageEvent>();
readonly rowClick = output<{ row: T; index: number }>();
readonly currentSort = signal<TableSort | null>(null);
readonly currentPage = signal(0);
readonly filterText = signal('');
readonly filteredData = computed(() => {
let result = this.data();
const filter = this.filterText().toLowerCase();
if (filter && this.filterable()) {
const cols = this.columns().filter(c => c.filterable !== false);
result = result.filter(row =>
cols.some(col => {
const val = (row as any)[col.key];
return String(val ?? '').toLowerCase().includes(filter);
})
);
}
const sort = this.currentSort();
if (sort) {
result = [...result].sort((a, b) => {
const aVal = (a as any)[sort.column];
const bVal = (b as any)[sort.column];
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sort.direction === 'asc' ? cmp : -cmp;
});
}
return result;
});
readonly pagedData = computed(() => {
const filtered = this.filteredData();
if (!this.paginated()) return filtered;
const start = this.currentPage() * this.pageSize();
return filtered.slice(start, start + this.pageSize());
});
readonly totalPages = computed(() => {
if (!this.paginated()) return 1;
return Math.ceil(this.filteredData().length / this.pageSize());
});
readonly pageNumbers = computed(() => {
const total = this.totalPages();
return Array.from({ length: total }, (_, i) => i);
});
onSort(column: TableColumn<T>): void {
if (!this.sortable() || column.sortable === false) return;
const current = this.currentSort();
let direction: 'asc' | 'desc' = 'asc';
if (current?.column === column.key) {
direction = current.direction === 'asc' ? 'desc' : 'asc';
}
this.currentSort.set({ column: column.key, direction });
this.sortChange.emit({ column: column.key, direction });
}
onPageChange(page: number): void {
if (page < 0 || page >= this.totalPages()) return;
this.currentPage.set(page);
this.pageChange.emit({ page, pageSize: this.pageSize() });
}
onRowClick(row: T, index: number): void {
this.rowClick.emit({ row, index });
}
getCellValue(row: T, column: TableColumn<T>): string {
const value = (row as any)[column.key];
if (column.format) {
return column.format(value, row);
}
return String(value ?? '');
}
getSortIcon(column: TableColumn<T>): string {
const sort = this.currentSort();
if (sort?.column !== column.key) return '\u2195';
return sort.direction === 'asc' ? '\u2191' : '\u2193';
}
onFilterInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.filterText.set(target.value);
this.currentPage.set(0);
}
}

View File

@@ -0,0 +1 @@
export * from './viz-gauge.component';

View File

@@ -0,0 +1 @@
<div class="viz-gauge" #chart></div>

View File

@@ -0,0 +1,9 @@
:host {
display: inline-block;
}
.viz-gauge {
::ng-deep {
.viz-gauge-svg { display: block; overflow: visible; }
}
}

View File

@@ -0,0 +1,238 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, viewChild, effect, computed,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { arc } from 'd3-shape';
import { scaleLinear } from 'd3-scale';
import { interpolate } from 'd3-interpolate';
import 'd3-transition';
import { VIZ_CONFIG } from '../../providers/viz-config.provider';
import { VizThemeService } from '../../services/viz-theme.service';
export interface GaugeThreshold {
value: number;
color: string;
}
@Component({
selector: 'viz-gauge',
standalone: true,
templateUrl: './viz-gauge.component.html',
styleUrl: './viz-gauge.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizGaugeComponent implements OnDestroy {
readonly value = input.required<number>();
readonly min = input(0);
readonly max = input(100);
readonly width = input(200);
readonly height = input(150);
readonly animate = input<boolean | undefined>(undefined);
readonly variant = input<'radial' | 'linear'>('radial');
readonly label = input<string>('');
readonly format = input<(v: number) => string>(v => v.toFixed(0));
readonly thresholds = input<GaugeThreshold[]>([
{ value: 33, color: '#10b981' },
{ value: 66, color: '#f59e0b' },
{ value: 100, color: '#ef4444' },
]);
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 svg: Selection<SVGSVGElement, unknown, null, undefined> | null = null;
readonly displayValue = computed(() => this.format()(this.value()));
constructor() {
effect(() => {
const _ = this.value();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
} else {
this.updateChart();
}
});
});
}
ngOnDestroy(): void {
this.svg?.remove();
}
private getColorForValue(value: number): string {
const thresholds = this.thresholds();
const pct = ((value - this.min()) / (this.max() - this.min())) * 100;
for (const t of thresholds) {
if (pct <= t.value) return t.color;
}
return thresholds[thresholds.length - 1]?.color ?? this.themeService.getColor(0);
}
private createChart(): void {
if (this.variant() === 'linear') {
this.createLinearGauge();
} else {
this.createRadialGauge();
}
}
private updateChart(): void {
if (this.variant() === 'linear') {
this.updateLinearGauge();
} else {
this.updateRadialGauge();
}
}
private createRadialGauge(): void {
const el = this.chartRef().nativeElement;
const w = this.width();
const h = this.height();
this.svg = select(el).append('svg')
.attr('width', w)
.attr('height', h)
.attr('class', 'viz-gauge-svg');
const radius = Math.min(w, h) * 0.45;
const g = this.svg.append('g')
.attr('transform', `translate(${w / 2},${h * 0.6})`);
// Background arc
const bgArc = arc<any>()
.innerRadius(radius * 0.7)
.outerRadius(radius)
.startAngle(-Math.PI * 0.75)
.endAngle(Math.PI * 0.75)
.cornerRadius(4);
g.append('path')
.attr('d', bgArc({}) ?? '')
.attr('fill', 'var(--viz-grid-color, #f3f4f6)');
// Value arc
g.append('path').attr('class', 'value-arc');
// Value text
g.append('text')
.attr('class', 'value-text')
.attr('text-anchor', 'middle')
.attr('dy', '-0.1em')
.attr('font-size', radius * 0.35)
.attr('font-weight', '600')
.attr('fill', 'var(--viz-text, #111827)');
// Label text
g.append('text')
.attr('class', 'label-text')
.attr('text-anchor', 'middle')
.attr('dy', '1.4em')
.attr('font-size', 'var(--viz-font-size-sm, 14px)')
.attr('fill', 'var(--viz-text-muted, #6b7280)');
this.updateRadialGauge();
}
private updateRadialGauge(): void {
if (!this.svg) return;
const w = this.width();
const h = this.height();
const radius = Math.min(w, h) * 0.45;
const value = this.value();
const minVal = this.min();
const maxVal = this.max();
const shouldAnimate = this.animate() ?? this.config.animate;
const duration = shouldAnimate ? this.config.animationDuration : 0;
const scale = scaleLinear()
.domain([minVal, maxVal])
.range([-Math.PI * 0.75, Math.PI * 0.75])
.clamp(true);
const valueArc = arc<any>()
.innerRadius(radius * 0.7)
.outerRadius(radius)
.startAngle(-Math.PI * 0.75)
.cornerRadius(4);
const endAngle = scale(value);
this.svg.select('.value-arc')
.transition()
.duration(duration)
.attrTween('d', function() {
const current = (this as any)._currentAngle ?? -Math.PI * 0.75;
const i = interpolate(current, endAngle);
(this as any)._currentAngle = endAngle;
return (t: number) => valueArc({ endAngle: i(t) }) ?? '';
})
.attr('fill', this.getColorForValue(value));
this.svg.select('.value-text').text(this.displayValue());
this.svg.select('.label-text').text(this.label());
}
private createLinearGauge(): void {
const el = this.chartRef().nativeElement;
const w = this.width();
const h = 40;
this.svg = select(el).append('svg')
.attr('width', w)
.attr('height', h)
.attr('class', 'viz-gauge-svg viz-gauge-linear');
// Background
this.svg.append('rect')
.attr('class', 'bg-bar')
.attr('x', 0).attr('y', 8)
.attr('width', w).attr('height', 12)
.attr('rx', 6)
.attr('fill', 'var(--viz-grid-color, #f3f4f6)');
// Value bar
this.svg.append('rect')
.attr('class', 'value-bar')
.attr('x', 0).attr('y', 8)
.attr('height', 12)
.attr('rx', 6);
// Value label
this.svg.append('text')
.attr('class', 'value-label')
.attr('y', 36)
.attr('font-size', 'var(--viz-font-size-sm, 14px)')
.attr('fill', 'var(--viz-text, #111827)')
.attr('font-weight', '600');
this.updateLinearGauge();
}
private updateLinearGauge(): void {
if (!this.svg) return;
const w = this.width();
const value = this.value();
const minVal = this.min();
const maxVal = this.max();
const pct = Math.max(0, Math.min(1, (value - minVal) / (maxVal - minVal)));
const shouldAnimate = this.animate() ?? this.config.animate;
const duration = shouldAnimate ? this.config.animationDuration : 0;
this.svg.select('.value-bar')
.transition()
.duration(duration)
.attr('width', w * pct)
.attr('fill', this.getColorForValue(value));
this.svg.select('.value-label')
.attr('x', w * pct)
.attr('text-anchor', pct > 0.1 ? 'end' : 'start')
.text(this.displayValue());
}
}

View File

@@ -0,0 +1 @@
export * from './viz-heatmap.component';

View File

@@ -0,0 +1 @@
<div class="viz-heatmap" #chart></div>

View File

@@ -0,0 +1,19 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-heatmap {
width: 100%;
::ng-deep {
.viz-heatmap-svg { display: block; overflow: visible; }
rect {
cursor: pointer;
transition: opacity var(--viz-transition);
&:hover { opacity: 0.8; stroke: var(--viz-text); stroke-width: 1; }
}
}
}

View File

@@ -0,0 +1,183 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleBand, scaleSequential } from 'd3-scale';
import { extent } from 'd3-array';
import { interpolateRgb } from 'd3-interpolate';
import 'd3-transition';
import { VIZ_CONFIG } from '../../providers/viz-config.provider';
import { VizResizeService } from '../../services/viz-resize.service';
import { VizTooltipService } from '../../services/viz-tooltip.service';
import type { HeatmapCell } from '../../types/chart.types';
import type { ChartMargin } from '../../types/config.types';
import type { ChartClickEvent, ChartHoverEvent } from '../../types/event.types';
@Component({
selector: 'viz-heatmap',
standalone: true,
templateUrl: './viz-heatmap.component.html',
styleUrl: './viz-heatmap.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizHeatmapComponent implements OnDestroy {
readonly data = input.required<HeatmapCell[]>();
readonly width = input<number | 'auto'>('auto');
readonly height = input<number>(300);
readonly margin = input<ChartMargin>({ top: 30, right: 20, bottom: 40, left: 60 });
readonly xLabels = input<string[]>([]);
readonly yLabels = input<string[]>([]);
readonly animate = input<boolean | undefined>(undefined);
readonly colorScale = input<'blues' | 'reds' | 'greens'>('blues');
readonly cellClick = output<ChartClickEvent<HeatmapCell>>();
readonly cellHover = output<ChartHoverEvent<HeatmapCell>>();
private readonly chartRef = viewChild.required<ElementRef<HTMLDivElement>>('chart');
private readonly ngZone = inject(NgZone);
private readonly config = inject(VIZ_CONFIG);
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.data();
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-heatmap-svg');
this.svg.append('g').attr('class', 'x-axis');
this.svg.append('g').attr('class', 'y-axis');
this.svg.append('g').attr('class', 'cells');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const data = this.data();
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;
this.svg.attr('width', w).attr('height', h);
const xLabels = this.xLabels().length
? this.xLabels()
: [...new Set(data.map(d => String(d.x)))];
const yLabels = this.yLabels().length
? this.yLabels()
: [...new Set(data.map(d => String(d.y)))];
const xScale = scaleBand<string>().domain(xLabels).range([0, innerW]).padding(0.05);
const yScale = scaleBand<string>().domain(yLabels).range([0, innerH]).padding(0.05);
const [minVal, maxVal] = extent(data, d => d.value) as [number, number];
const color = scaleSequential(interpolateRgb('#f7fbff', '#08519c')).domain([minVal, maxVal]);
this.svg.select<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.call((g) => {
g.selectAll('text').remove();
xLabels.forEach(label => {
g.append('text')
.attr('x', (xScale(label) ?? 0) + xScale.bandwidth() / 2)
.attr('y', 15)
.attr('text-anchor', 'middle')
.attr('fill', 'var(--viz-text-muted)')
.attr('font-size', 'var(--viz-font-size-xs)')
.text(label);
});
});
this.svg.select<SVGGElement>('.y-axis')
.attr('transform', `translate(${m.left},${m.top})`)
.call((g) => {
g.selectAll('text').remove();
yLabels.forEach(label => {
g.append('text')
.attr('x', -8)
.attr('y', (yScale(label) ?? 0) + yScale.bandwidth() / 2)
.attr('text-anchor', 'end')
.attr('dominant-baseline', 'middle')
.attr('fill', 'var(--viz-text-muted)')
.attr('font-size', 'var(--viz-font-size-xs)')
.text(label);
});
});
const cellsG = this.svg.select('.cells').attr('transform', `translate(${m.left},${m.top})`);
const cells = cellsG.selectAll<SVGRectElement, HeatmapCell>('rect')
.data(data, (d: HeatmapCell) => `${d.x}-${d.y}`);
cells.exit().transition().duration(duration).attr('opacity', 0).remove();
cells.enter()
.append('rect')
.attr('rx', 2)
.attr('opacity', 0)
.merge(cells)
.on('mouseenter', (event: MouseEvent, d) => {
if (this.config.tooltips) {
this.tooltipService.show(event.clientX, event.clientY,
`${d.x}, ${d.y}: <strong>${d.value}</strong>`);
}
this.ngZone.run(() => this.cellHover.emit({ data: d, index: data.indexOf(d), event }));
})
.on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY))
.on('mouseleave', (event: MouseEvent) => {
this.tooltipService.hide();
this.ngZone.run(() => this.cellHover.emit({ data: null, index: -1, event }));
})
.on('click', (event: MouseEvent, d) => {
this.ngZone.run(() => this.cellClick.emit({ data: d, index: data.indexOf(d), event }));
})
.transition()
.duration(duration)
.attr('x', d => xScale(String(d.x)) ?? 0)
.attr('y', d => yScale(String(d.y)) ?? 0)
.attr('width', xScale.bandwidth())
.attr('height', yScale.bandwidth())
.attr('fill', d => color(d.value))
.attr('opacity', 1);
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-histogram.component';

View File

@@ -0,0 +1 @@
<div class="viz-histogram" #chart></div>

View File

@@ -0,0 +1,27 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-histogram {
width: 100%;
::ng-deep {
.viz-histogram-svg {
display: block;
overflow: visible;
}
.domain { stroke: var(--viz-axis-color); }
.tick line { stroke: var(--viz-tick-color); }
.tick text { fill: var(--viz-text-muted); font-size: var(--viz-font-size-xs); }
.grid-line { stroke: var(--viz-grid-color); stroke-dasharray: 2, 2; }
rect {
cursor: pointer;
transition: opacity var(--viz-transition);
&:hover { opacity: 0.8; }
}
}
}

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

View File

@@ -0,0 +1 @@
export * from './viz-legend.component';

View File

@@ -0,0 +1,18 @@
<div class="viz-legend" [class]="'viz-legend--' + position()">
@for (item of items(); track item.label; let i = $index) {
<button
class="viz-legend__item"
[class.viz-legend__item--inactive]="!isActive(item)"
[class.viz-legend__item--interactive]="interactive()"
(click)="onItemClick(item, i)"
type="button"
>
<span
class="viz-legend__swatch"
[style.background-color]="getColor(item, i)"
[style.opacity]="isActive(item) ? 1 : 0.3"
></span>
<span class="viz-legend__label">{{ item.label }}</span>
</button>
}
</div>

View File

@@ -0,0 +1,59 @@
:host {
display: block;
}
.viz-legend {
display: flex;
flex-wrap: wrap;
gap: var(--viz-spacing-md, 12px);
padding: var(--viz-spacing-sm) 0;
&--top,
&--bottom {
flex-direction: row;
justify-content: flex-start;
}
&--left,
&--right {
flex-direction: column;
align-items: flex-start;
}
&__item {
display: inline-flex;
align-items: center;
gap: var(--viz-spacing-sm, 6px);
background: none;
border: none;
padding: 4px var(--viz-spacing-sm, 8px);
font-size: var(--viz-font-size-sm);
color: var(--viz-text);
border-radius: var(--viz-radius-sm);
transition: opacity var(--viz-transition), background-color var(--viz-transition);
&--interactive {
cursor: pointer;
&:hover {
background-color: var(--viz-grid-color);
}
}
&--inactive {
opacity: 0.4;
}
}
&__swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
&__label {
white-space: nowrap;
}
}

View File

@@ -0,0 +1,43 @@
import {
Component, ChangeDetectionStrategy, inject, input, output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { VizThemeService } from '../../services/viz-theme.service';
export interface LegendItem {
label: string;
color?: string;
active?: boolean;
}
@Component({
selector: 'viz-legend',
standalone: true,
imports: [CommonModule],
templateUrl: './viz-legend.component.html',
styleUrl: './viz-legend.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizLegendComponent {
readonly items = input.required<LegendItem[]>();
readonly position = input<'top' | 'bottom' | 'left' | 'right'>('top');
readonly interactive = input(true);
readonly itemClick = output<{ item: LegendItem; index: number }>();
private readonly themeService = inject(VizThemeService);
getColor(item: LegendItem, index: number): string {
return item.color ?? this.themeService.getColor(index);
}
onItemClick(item: LegendItem, index: number): void {
if (this.interactive()) {
this.itemClick.emit({ item, index });
}
}
isActive(item: LegendItem): boolean {
return item.active !== false;
}
}

View File

@@ -0,0 +1 @@
export * from './viz-line-chart.component';

View File

@@ -0,0 +1,9 @@
<div class="viz-line-chart" [class.viz-line-chart--legend-left]="legend().visible && legend().position === 'left'" [class.viz-line-chart--legend-right]="legend().visible && legend().position === 'right'">
@if (legend().visible && (legend().position === 'top' || legend().position === 'left')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
<div #chart></div>
@if (legend().visible && (legend().position === 'bottom' || legend().position === 'right')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
</div>

View File

@@ -0,0 +1,57 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-line-chart {
display: flex;
flex-direction: column;
width: 100%;
&--legend-left,
&--legend-right {
flex-direction: row;
}
> div:not(.viz-legend) {
flex: 1;
min-width: 0;
}
::ng-deep {
.viz-line-chart-svg {
display: block;
overflow: visible;
}
.domain {
stroke: var(--viz-axis-color);
}
.tick line {
stroke: var(--viz-tick-color);
}
.tick text {
fill: var(--viz-text-muted);
font-size: var(--viz-font-size-xs);
}
.grid-line {
stroke: var(--viz-grid-color);
stroke-dasharray: 2, 2;
}
circle {
cursor: pointer;
stroke: var(--viz-bg, #fff);
stroke-width: 2;
transition: r var(--viz-transition);
&:hover {
r: 5;
}
}
}
}

View File

@@ -0,0 +1,290 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect, computed, signal,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleLinear, scaleTime } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { line, area, curveBasis, curveCardinal, curveLinear, curveMonotoneX, curveStep, CurveFactory } from 'd3-shape';
import { extent, max } 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 { CartesianDataPoint, ChartSeries } from '../../types/chart.types';
import type { ChartMargin, AxisConfig, LegendConfig } from '../../types/config.types';
import type { ChartClickEvent, ChartHoverEvent } from '../../types/event.types';
import { VizLegendComponent, LegendItem } from '../viz-legend/viz-legend.component';
const CURVES: Record<string, CurveFactory> = {
linear: curveLinear,
basis: curveBasis,
cardinal: curveCardinal,
monotone: curveMonotoneX,
step: curveStep,
};
interface FlatDotPoint extends CartesianDataPoint {
seriesIndex: number;
seriesName: string;
seriesColor: string | undefined;
}
@Component({
selector: 'viz-line-chart',
standalone: true,
imports: [VizLegendComponent],
templateUrl: './viz-line-chart.component.html',
styleUrl: './viz-line-chart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizLineChartComponent implements OnDestroy {
readonly series = input.required<ChartSeries[]>();
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 legend = input<LegendConfig>({ visible: true, position: 'top' });
readonly animate = input<boolean | undefined>(undefined);
readonly curve = input<'linear' | 'basis' | 'cardinal' | 'monotone' | 'step'>('monotone');
readonly showDots = input(true);
readonly areaFill = input(false);
readonly pointClick = output<ChartClickEvent<CartesianDataPoint>>();
readonly pointHover = output<ChartHoverEvent<CartesianDataPoint>>();
readonly hiddenItems = signal<Set<string>>(new Set());
readonly legendItems = computed(() =>
this.series().map((s, i) => ({
label: s.name,
color: s.color ?? this.themeService.getColor(i),
active: !this.hiddenItems().has(s.name),
}))
);
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.series();
const __ = this.hiddenItems();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
this.setupResize();
} else {
this.updateChart();
}
});
});
}
onLegendClick(event: { item: LegendItem; index: number }): void {
const hidden = new Set(this.hiddenItems());
if (hidden.has(event.item.label)) {
hidden.delete(event.item.label);
} else {
hidden.add(event.item.label);
}
this.hiddenItems.set(hidden);
}
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-line-chart-svg');
this.svg.append('g').attr('class', 'x-axis');
this.svg.append('g').attr('class', 'y-axis');
this.svg.append('g').attr('class', 'areas');
this.svg.append('g').attr('class', 'lines');
this.svg.append('g').attr('class', 'dots');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const allSeries = this.series().filter(s => s.visible !== false && !this.hiddenItems().has(s.name));
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 yAxisConfig = this.yAxis();
const curveFactory = CURVES[this.curve()] ?? curveMonotoneX;
this.svg.attr('width', w).attr('height', h);
// Compute domains across all series
const allPoints: CartesianDataPoint[] = [];
for (const s of allSeries) {
for (const d of s.data) {
allPoints.push(d);
}
}
const isTimeScale = allPoints.length > 0 && allPoints[0].x instanceof Date;
const xScale = isTimeScale
? scaleTime()
.domain(extent(allPoints, (d: CartesianDataPoint) => d.x as Date) as [Date, Date])
.range([0, innerW])
: scaleLinear()
.domain(extent(allPoints, (d: CartesianDataPoint) => d.x as number) as [number, number])
.range([0, innerW]);
const yDomainMax = yAxisConfig.max ?? (max(allPoints, (d: CartesianDataPoint) => d.y) ?? 0);
const yScale = scaleLinear()
.domain([yAxisConfig.min ?? 0, yDomainMax])
.range([innerH, 0])
.nice();
// Axes
this.svg.select<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.call(axisBottom(xScale as any).ticks(this.xAxis().tickCount ?? 6));
const yAxisG = this.svg.select<SVGGElement>('.y-axis')
.attr('transform', `translate(${m.left},${m.top})`)
.call(axisLeft(yScale).ticks(yAxisConfig.tickCount ?? 5));
if (yAxisConfig.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');
}
// Line generator
const lineGen = line<CartesianDataPoint>()
.x(d => (xScale as any)(d.x))
.y(d => yScale(d.y))
.curve(curveFactory);
// Area generator
const areaGen = area<CartesianDataPoint>()
.x(d => (xScale as any)(d.x))
.y0(innerH)
.y1(d => yScale(d.y))
.curve(curveFactory);
// Areas
if (this.areaFill()) {
const areas = this.svg.select('.areas')
.attr('transform', `translate(${m.left},${m.top})`)
.selectAll<SVGPathElement, ChartSeries>('path')
.data(allSeries, (d: ChartSeries) => d.name);
areas.exit().remove();
areas.enter()
.append('path')
.merge(areas)
.transition()
.duration(duration)
.attr('d', (d: ChartSeries) => areaGen(d.data) ?? '')
.attr('fill', (d: ChartSeries, i: number) => d.color ?? this.themeService.getColor(i))
.attr('opacity', 0.15);
}
// Lines
const lines = this.svg.select('.lines')
.attr('transform', `translate(${m.left},${m.top})`)
.selectAll<SVGPathElement, ChartSeries>('path')
.data(allSeries, (d: ChartSeries) => d.name);
lines.exit().remove();
lines.enter()
.append('path')
.attr('fill', 'none')
.attr('stroke-width', 2)
.merge(lines)
.transition()
.duration(duration)
.attr('d', (d: ChartSeries) => lineGen(d.data) ?? '')
.attr('stroke', (d: ChartSeries, i: number) => d.color ?? this.themeService.getColor(i));
// Dots
if (this.showDots()) {
const dotsG = this.svg.select('.dots')
.attr('transform', `translate(${m.left},${m.top})`);
const flatData: FlatDotPoint[] = [];
allSeries.forEach((s, si) => {
for (const d of s.data) {
flatData.push({ ...d, seriesIndex: si, seriesName: s.name, seriesColor: s.color });
}
});
const dots = dotsG.selectAll<SVGCircleElement, FlatDotPoint>('circle')
.data(flatData);
dots.exit().remove();
dots.enter()
.append('circle')
.attr('r', 3)
.merge(dots)
.on('mouseenter', (event: MouseEvent, d: FlatDotPoint) => {
if (this.config.tooltips) {
const xLabel = d.x instanceof Date ? d.x.toLocaleDateString() : d.x;
this.tooltipService.show(event.clientX, event.clientY,
`<strong>${d.seriesName}</strong><br>${xLabel}: ${d.y}`);
}
this.ngZone.run(() => this.pointHover.emit({ data: d, index: 0, event }));
})
.on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY))
.on('mouseleave', (event: MouseEvent) => {
this.tooltipService.hide();
this.ngZone.run(() => this.pointHover.emit({ data: null, index: -1, event }));
})
.on('click', (event: MouseEvent, d: FlatDotPoint) => {
this.ngZone.run(() => this.pointClick.emit({ data: d, index: 0, event }));
})
.transition()
.duration(duration)
.attr('cx', (d: FlatDotPoint) => (xScale as any)(d.x))
.attr('cy', (d: FlatDotPoint) => yScale(d.y))
.attr('fill', (d: FlatDotPoint) => d.seriesColor ?? this.themeService.getColor(d.seriesIndex));
}
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-pie-chart.component';

View File

@@ -0,0 +1,9 @@
<div class="viz-pie-chart" [class.viz-pie-chart--legend-left]="legend().visible && legend().position === 'left'" [class.viz-pie-chart--legend-right]="legend().visible && legend().position === 'right'">
@if (legend().visible && (legend().position === 'top' || legend().position === 'left')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
<div #chart></div>
@if (legend().visible && (legend().position === 'bottom' || legend().position === 'right')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
</div>

View File

@@ -0,0 +1,39 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-pie-chart {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
&--legend-left,
&--legend-right {
flex-direction: row;
align-items: center;
}
> div:not(.viz-legend) {
flex: 1;
min-width: 0;
}
::ng-deep {
.viz-pie-chart-svg {
display: block;
overflow: visible;
}
path {
cursor: pointer;
transition: filter var(--viz-transition);
&:hover {
filter: brightness(1.05);
}
}
}
}

View File

@@ -0,0 +1,231 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect, computed, signal,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { pie, arc, PieArcDatum } from 'd3-shape';
import { interpolate } from 'd3-interpolate';
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 { ChartDataPoint } from '../../types/chart.types';
import type { ChartMargin, LegendConfig } from '../../types/config.types';
import type { ChartClickEvent, ChartHoverEvent } from '../../types/event.types';
import { VizLegendComponent, LegendItem } from '../viz-legend/viz-legend.component';
@Component({
selector: 'viz-pie-chart',
standalone: true,
imports: [VizLegendComponent],
templateUrl: './viz-pie-chart.component.html',
styleUrl: './viz-pie-chart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizPieChartComponent implements OnDestroy {
readonly data = input.required<ChartDataPoint[]>();
readonly width = input<number | 'auto'>('auto');
readonly height = input<number>(300);
readonly margin = input<ChartMargin>({ top: 20, right: 20, bottom: 20, left: 20 });
readonly legend = input<LegendConfig>({ visible: true, position: 'right' });
readonly animate = input<boolean | undefined>(undefined);
readonly donut = input(false);
readonly innerRadius = input(0.6);
readonly labels = input(true);
readonly sliceClick = output<ChartClickEvent<ChartDataPoint>>();
readonly sliceHover = output<ChartHoverEvent<ChartDataPoint>>();
readonly hiddenItems = signal<Set<string>>(new Set());
readonly legendItems = computed(() =>
this.data().map((d, i) => ({
label: d.label,
color: d.color ?? this.themeService.getColor(i),
active: !this.hiddenItems().has(d.label),
}))
);
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.data();
const __ = this.hiddenItems();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
this.setupResize();
} else {
this.updateChart();
}
});
});
}
onLegendClick(event: { item: LegendItem; index: number }): void {
const hidden = new Set(this.hiddenItems());
if (hidden.has(event.item.label)) {
hidden.delete(event.item.label);
} else {
hidden.add(event.item.label);
}
this.hiddenItems.set(hidden);
}
ngOnDestroy(): void {
this.resizeCleanup?.();
this.svg?.remove();
}
private getWidth(): number {
const w = this.width();
return w === 'auto' ? (this.chartRef().nativeElement.clientWidth || 400) : w;
}
private createChart(): void {
const el = this.chartRef().nativeElement;
this.svg = select(el)
.append('svg')
.attr('class', 'viz-pie-chart-svg');
this.svg.append('g').attr('class', 'slices');
this.svg.append('g').attr('class', 'labels');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const data = this.data().filter(d => !this.hiddenItems().has(d.label));
const w = this.getWidth();
const h = this.height();
const m = this.margin();
const shouldAnimate = this.animate() ?? this.config.animate;
const duration = shouldAnimate ? this.config.animationDuration : 0;
const radius = Math.min(w - m.left - m.right, h - m.top - m.bottom) / 2;
const inner = this.donut() ? radius * this.innerRadius() : 0;
this.svg.attr('width', w).attr('height', h);
const pieGen = pie<ChartDataPoint>()
.value(d => d.value)
.sort(null);
const arcGen = arc<PieArcDatum<ChartDataPoint>>()
.innerRadius(inner)
.outerRadius(radius);
const arcHover = arc<PieArcDatum<ChartDataPoint>>()
.innerRadius(inner)
.outerRadius(radius + 6);
const labelArc = arc<PieArcDatum<ChartDataPoint>>()
.innerRadius(radius * 0.7)
.outerRadius(radius * 0.7);
const pieData = pieGen(data);
const centerX = w / 2;
const centerY = h / 2;
// Slices
const slicesG = this.svg.select('.slices')
.attr('transform', `translate(${centerX},${centerY})`);
const slices = slicesG.selectAll<SVGPathElement, PieArcDatum<ChartDataPoint>>('path')
.data(pieData, d => d.data.label);
slices.exit().transition().duration(duration).attrTween('d', function(datum) {
const d = datum as PieArcDatum<ChartDataPoint>;
const end = { ...d, startAngle: d.endAngle };
const i = interpolate(d, end);
return (t: number) => arcGen(i(t)) ?? '';
}).remove();
const enter = slices.enter()
.append('path')
.attr('fill', (d, i) => d.data.color ?? this.themeService.getColor(i))
.attr('stroke', 'var(--viz-bg, #fff)')
.attr('stroke-width', 2);
enter.merge(slices)
.on('mouseenter', (event: MouseEvent, d) => {
select(event.currentTarget as SVGPathElement)
.transition().duration(100)
.attr('d', arcHover(d) ?? '');
if (this.config.tooltips) {
const total = data.reduce((sum, dp) => sum + dp.value, 0);
const pct = ((d.data.value / total) * 100).toFixed(1);
this.tooltipService.show(event.clientX, event.clientY,
`<strong>${d.data.label}</strong>: ${d.data.value} (${pct}%)`);
}
this.ngZone.run(() => this.sliceHover.emit({ data: d.data, index: d.index, event }));
})
.on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY))
.on('mouseleave', (event: MouseEvent, d) => {
select(event.currentTarget as SVGPathElement)
.transition().duration(100)
.attr('d', arcGen(d) ?? '');
this.tooltipService.hide();
this.ngZone.run(() => this.sliceHover.emit({ data: null, index: -1, event }));
})
.on('click', (event: MouseEvent, d) => {
this.ngZone.run(() => this.sliceClick.emit({ data: d.data, index: d.index, event }));
})
.transition()
.duration(duration)
.attrTween('d', function(d) {
const current = (this as any)._current ?? { startAngle: 0, endAngle: 0 };
const i = interpolate(current, d);
(this as any)._current = d;
return (t: number) => arcGen(i(t)) ?? '';
})
.attr('fill', (d, i) => d.data.color ?? this.themeService.getColor(i));
// Labels
if (this.labels()) {
const labelsG = this.svg.select('.labels')
.attr('transform', `translate(${centerX},${centerY})`);
const labelSel = labelsG.selectAll<SVGTextElement, PieArcDatum<ChartDataPoint>>('text')
.data(pieData, d => d.data.label);
labelSel.exit().remove();
labelSel.enter()
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', 'var(--viz-font-size-xs, 12px)')
.attr('fill', 'var(--viz-text, #111827)')
.merge(labelSel)
.transition()
.duration(duration)
.attr('transform', d => `translate(${labelArc.centroid(d)})`)
.text(d => {
const angle = d.endAngle - d.startAngle;
return angle > 0.3 ? d.data.label : '';
});
}
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-progress-bar.component';

View File

@@ -0,0 +1,24 @@
<div class="viz-progress-bar">
@if (showLabel()) {
<div class="viz-progress-bar__header">
<span class="viz-progress-bar__percentage">{{ percentage() | number:'1.0-0' }}%</span>
</div>
}
<div class="viz-progress-bar__track" [style.height.px]="height()">
@if (computedSegments()) {
@for (seg of computedSegments(); track seg.label ?? $index) {
<div
class="viz-progress-bar__segment"
[style.width.%]="seg.width"
[style.background-color]="seg.color"
></div>
}
} @else {
<div
class="viz-progress-bar__fill"
[style.width.%]="percentage()"
[style.background-color]="barColor()"
></div>
}
</div>
</div>

View File

@@ -0,0 +1,49 @@
:host {
display: block;
width: 100%;
}
.viz-progress-bar {
&__header {
display: flex;
justify-content: flex-end;
margin-bottom: var(--viz-spacing-xs);
}
&__percentage {
font-size: var(--viz-font-size-sm);
font-weight: 600;
color: var(--viz-text);
}
&__track {
width: 100%;
background: var(--viz-grid-color, #f3f4f6);
border-radius: 999px;
overflow: hidden;
display: flex;
}
&__fill {
height: 100%;
border-radius: 999px;
transition: width var(--viz-transition);
}
&__segment {
height: 100%;
transition: width var(--viz-transition);
&:first-child {
border-radius: 999px 0 0 999px;
}
&:last-child {
border-radius: 0 999px 999px 0;
}
&:only-child {
border-radius: 999px;
}
}
}

View File

@@ -0,0 +1,48 @@
import {
Component, ChangeDetectionStrategy, input, computed, inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { VizThemeService } from '../../services/viz-theme.service';
export interface ProgressSegment {
value: number;
color?: string;
label?: string;
}
@Component({
selector: 'viz-progress-bar',
standalone: true,
imports: [CommonModule],
templateUrl: './viz-progress-bar.component.html',
styleUrl: './viz-progress-bar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizProgressBarComponent {
readonly value = input<number>(0);
readonly max = input(100);
readonly segments = input<ProgressSegment[] | undefined>(undefined);
readonly showLabel = input(true);
readonly color = input<string | undefined>(undefined);
readonly height = input(8);
private readonly themeService = inject(VizThemeService);
readonly percentage = computed(() => {
return Math.min(100, Math.max(0, (this.value() / this.max()) * 100));
});
readonly barColor = computed(() => this.color() ?? this.themeService.getColor(0));
readonly computedSegments = computed(() => {
const segs = this.segments();
if (!segs) return null;
const total = segs.reduce((sum, s) => sum + s.value, 0);
return segs.map((s, i) => ({
...s,
width: (s.value / total) * 100,
color: s.color ?? this.themeService.getColor(i),
}));
});
}

View File

@@ -0,0 +1 @@
export * from './viz-progress-ring.component';

View File

@@ -0,0 +1,33 @@
<div class="viz-progress-ring">
<svg
[attr.width]="size()"
[attr.height]="size()"
[attr.viewBox]="viewBox()"
>
<circle
class="viz-progress-ring__bg"
[attr.cx]="center()"
[attr.cy]="center()"
[attr.r]="radius()"
[attr.stroke-width]="thickness()"
fill="none"
stroke="var(--viz-grid-color, #f3f4f6)"
/>
<circle
class="viz-progress-ring__value"
[attr.cx]="center()"
[attr.cy]="center()"
[attr.r]="radius()"
[attr.stroke-width]="thickness()"
[attr.stroke]="strokeColor()"
[attr.stroke-dasharray]="circumference()"
[attr.stroke-dashoffset]="dashOffset()"
fill="none"
stroke-linecap="round"
[attr.transform]="'rotate(-90 ' + center() + ' ' + center() + ')'"
/>
</svg>
@if (showValue()) {
<span class="viz-progress-ring__label">{{ displayValue() }}</span>
}
</div>

View File

@@ -0,0 +1,25 @@
:host {
display: inline-block;
}
.viz-progress-ring {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
&__value {
transition: stroke-dashoffset var(--viz-transition);
}
&__label {
position: absolute;
font-size: var(--viz-font-size-sm);
font-weight: 600;
color: var(--viz-text);
}
}

View File

@@ -0,0 +1,38 @@
import {
Component, ChangeDetectionStrategy, input, computed, inject,
} from '@angular/core';
import { VizThemeService } from '../../services/viz-theme.service';
@Component({
selector: 'viz-progress-ring',
standalone: true,
templateUrl: './viz-progress-ring.component.html',
styleUrl: './viz-progress-ring.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizProgressRingComponent {
readonly value = input.required<number>();
readonly max = input(100);
readonly size = input(80);
readonly thickness = input(8);
readonly color = input<string | undefined>(undefined);
readonly showValue = input(true);
readonly format = input<(v: number, max: number) => string>((v, max) => `${Math.round((v / max) * 100)}%`);
private readonly themeService = inject(VizThemeService);
readonly radius = computed(() => (this.size() - this.thickness()) / 2);
readonly circumference = computed(() => 2 * Math.PI * this.radius());
readonly dashOffset = computed(() => {
const pct = Math.min(1, Math.max(0, this.value() / this.max()));
return this.circumference() * (1 - pct);
});
readonly strokeColor = computed(() => this.color() ?? this.themeService.getColor(0));
readonly displayValue = computed(() => this.format()(this.value(), this.max()));
readonly viewBox = computed(() => `0 0 ${this.size()} ${this.size()}`);
readonly center = computed(() => this.size() / 2);
}

View File

@@ -0,0 +1 @@
export * from './viz-scatter-chart.component';

View File

@@ -0,0 +1,9 @@
<div class="viz-scatter-chart" [class.viz-scatter-chart--legend-left]="legend().visible && legend().position === 'left'" [class.viz-scatter-chart--legend-right]="legend().visible && legend().position === 'right'">
@if (legend().visible && (legend().position === 'top' || legend().position === 'left')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
<div #chart></div>
@if (legend().visible && (legend().position === 'bottom' || legend().position === 'right')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
</div>

View File

@@ -0,0 +1,55 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-scatter-chart {
display: flex;
flex-direction: column;
width: 100%;
&--legend-left,
&--legend-right {
flex-direction: row;
}
> div:not(.viz-legend) {
flex: 1;
min-width: 0;
}
::ng-deep {
.viz-scatter-chart-svg {
display: block;
overflow: visible;
}
.domain {
stroke: var(--viz-axis-color);
}
.tick line {
stroke: var(--viz-tick-color);
}
.tick text {
fill: var(--viz-text-muted);
font-size: var(--viz-font-size-xs);
}
.grid-line {
stroke: var(--viz-grid-color);
stroke-dasharray: 2, 2;
}
circle {
cursor: pointer;
transition: opacity var(--viz-transition);
&:hover {
opacity: 1;
}
}
}
}

View File

@@ -0,0 +1,222 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect, computed, signal,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleLinear, scaleSqrt } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { extent, max } 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 { ScatterDataPoint, ChartSeries } from '../../types/chart.types';
import type { ChartMargin, AxisConfig, LegendConfig } from '../../types/config.types';
import type { ChartClickEvent, ChartHoverEvent } from '../../types/event.types';
import { VizLegendComponent, LegendItem } from '../viz-legend/viz-legend.component';
@Component({
selector: 'viz-scatter-chart',
standalone: true,
imports: [VizLegendComponent],
templateUrl: './viz-scatter-chart.component.html',
styleUrl: './viz-scatter-chart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizScatterChartComponent implements OnDestroy {
readonly series = input.required<ChartSeries<ScatterDataPoint>[]>();
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 legend = input<LegendConfig>({ visible: true, position: 'top' });
readonly animate = input<boolean | undefined>(undefined);
readonly bubbleSize = input<[number, number]>([4, 30]);
readonly pointClick = output<ChartClickEvent<ScatterDataPoint>>();
readonly pointHover = output<ChartHoverEvent<ScatterDataPoint>>();
readonly hiddenItems = signal<Set<string>>(new Set());
readonly legendItems = computed(() =>
this.series().map((s, i) => ({
label: s.name,
color: s.color ?? this.themeService.getColor(i),
active: !this.hiddenItems().has(s.name),
}))
);
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.series();
const __ = this.hiddenItems();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
this.setupResize();
} else {
this.updateChart();
}
});
});
}
onLegendClick(event: { item: LegendItem; index: number }): void {
const hidden = new Set(this.hiddenItems());
if (hidden.has(event.item.label)) {
hidden.delete(event.item.label);
} else {
hidden.add(event.item.label);
}
this.hiddenItems.set(hidden);
}
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-scatter-chart-svg');
this.svg.append('g').attr('class', 'x-axis');
this.svg.append('g').attr('class', 'y-axis');
this.svg.append('g').attr('class', 'points');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const allSeries = this.series().filter(s => s.visible !== false && !this.hiddenItems().has(s.name));
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 xAxisConfig = this.xAxis();
const yAxisConfig = this.yAxis();
const [minSize, maxSize] = this.bubbleSize();
this.svg.attr('width', w).attr('height', h);
const allPoints: ScatterDataPoint[] = [];
for (const s of allSeries) { for (const d of s.data) { allPoints.push(d); } }
const xScale = scaleLinear()
.domain(extent(allPoints, (d: ScatterDataPoint) => d.x as number) as [number, number])
.range([0, innerW])
.nice();
const yExtent = extent(allPoints, (d: ScatterDataPoint) => d.y);
const yScale = scaleLinear()
.domain([
yAxisConfig.min ?? (yExtent[0] ?? 0),
yAxisConfig.max ?? (yExtent[1] ?? 0),
])
.range([innerH, 0])
.nice();
const hasSizes = allPoints.some((d: ScatterDataPoint) => d.size != null);
const sizeScale = hasSizes
? scaleSqrt()
.domain([0, max(allPoints, (d: ScatterDataPoint) => d.size ?? 0) ?? 1])
.range([minSize, maxSize])
: null;
// Axes
this.svg.select<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.call(axisBottom(xScale).ticks(xAxisConfig.tickCount ?? 6));
const yAxisG = this.svg.select<SVGGElement>('.y-axis')
.attr('transform', `translate(${m.left},${m.top})`)
.call(axisLeft(yScale).ticks(yAxisConfig.tickCount ?? 5));
if (yAxisConfig.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');
}
// Points
const pointsG = this.svg.select('.points').attr('transform', `translate(${m.left},${m.top})`);
const flatData: Array<ScatterDataPoint & { seriesIndex: number; seriesColor: string | undefined }> = [];
allSeries.forEach((s, si) => {
for (const d of s.data) {
flatData.push({ ...d, seriesIndex: si, seriesColor: s.color });
}
});
const circles = pointsG.selectAll<SVGCircleElement, typeof flatData[0]>('circle')
.data(flatData);
circles.exit().transition().duration(duration).attr('r', 0).remove();
circles.enter()
.append('circle')
.attr('r', 0)
.attr('opacity', 0.7)
.merge(circles)
.on('mouseenter', (event: MouseEvent, d) => {
if (this.config.tooltips) {
const label = d.label ?? `(${d.x}, ${d.y})`;
const sizeText = d.size != null ? ` | Size: ${d.size}` : '';
this.tooltipService.show(event.clientX, event.clientY, `<strong>${label}</strong>${sizeText}`);
}
this.ngZone.run(() => this.pointHover.emit({ data: d, index: 0, event }));
})
.on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY))
.on('mouseleave', (event: MouseEvent) => {
this.tooltipService.hide();
this.ngZone.run(() => this.pointHover.emit({ data: null, index: -1, event }));
})
.on('click', (event: MouseEvent, d) => {
this.ngZone.run(() => this.pointClick.emit({ data: d, index: 0, event }));
})
.transition()
.duration(duration)
.attr('cx', d => xScale(d.x as number))
.attr('cy', d => yScale(d.y))
.attr('r', d => sizeScale ? sizeScale(d.size ?? 0) : minSize)
.attr('fill', d => d.seriesColor ?? this.themeService.getColor(d.seriesIndex));
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-sparkline.component';

View File

@@ -0,0 +1 @@
<span class="viz-sparkline" #chart></span>

View File

@@ -0,0 +1,14 @@
:host {
display: inline-block;
vertical-align: middle;
}
.viz-sparkline {
display: inline-block;
::ng-deep {
.viz-sparkline-svg {
display: block;
}
}
}

View File

@@ -0,0 +1,132 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, viewChild, effect,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleLinear } from 'd3-scale';
import { line, area, curveMonotoneX } from 'd3-shape';
import { extent } from 'd3-array';
import 'd3-transition';
import { VIZ_CONFIG } from '../../providers/viz-config.provider';
import { VizThemeService } from '../../services/viz-theme.service';
import { withOpacity } from '../../utils/color.utils';
@Component({
selector: 'viz-sparkline',
standalone: true,
templateUrl: './viz-sparkline.component.html',
styleUrl: './viz-sparkline.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizSparklineComponent implements OnDestroy {
readonly data = input.required<number[]>();
readonly width = input(120);
readonly height = input(32);
readonly type = input<'line' | 'bar' | 'area'>('line');
readonly color = input<string | undefined>(undefined);
readonly showLastValue = input(false);
readonly animate = input<boolean | undefined>(undefined);
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 svg: Selection<SVGSVGElement, unknown, null, undefined> | null = null;
constructor() {
effect(() => {
const _ = this.data();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
} else {
this.updateChart();
}
});
});
}
ngOnDestroy(): void {
this.svg?.remove();
}
private createChart(): void {
const el = this.chartRef().nativeElement;
this.svg = select(el).append('svg')
.attr('class', 'viz-sparkline-svg')
.attr('width', this.width())
.attr('height', this.height());
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const data = this.data();
const w = this.width();
const h = this.height();
const sparkColor = this.color() ?? this.themeService.getColor(0);
const shouldAnimate = this.animate() ?? this.config.animate;
const duration = shouldAnimate ? this.config.animationDuration : 0;
this.svg.attr('width', w).attr('height', h);
this.svg.selectAll('*').remove();
if (data.length === 0) return;
const xScale = scaleLinear().domain([0, data.length - 1]).range([2, w - 2]);
const [minVal, maxVal] = extent(data) as [number, number];
const yScale = scaleLinear().domain([minVal, maxVal]).range([h - 2, 2]);
const type = this.type();
if (type === 'bar') {
const barW = Math.max(1, (w - 4) / data.length - 1);
this.svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('x', (_, i) => xScale(i) - barW / 2)
.attr('y', d => yScale(d))
.attr('width', barW)
.attr('height', d => h - 2 - yScale(d))
.attr('fill', sparkColor)
.attr('rx', 0.5);
} else {
if (type === 'area') {
const areaGen = area<number>()
.x((_, i) => xScale(i))
.y0(h)
.y1(d => yScale(d))
.curve(curveMonotoneX);
this.svg.append('path')
.attr('d', areaGen(data) ?? '')
.attr('fill', withOpacity(sparkColor, 0.15));
}
const lineGen = line<number>()
.x((_, i) => xScale(i))
.y(d => yScale(d))
.curve(curveMonotoneX);
this.svg.append('path')
.attr('d', lineGen(data) ?? '')
.attr('fill', 'none')
.attr('stroke', sparkColor)
.attr('stroke-width', 1.5);
// Last point dot
if (this.showLastValue() && data.length > 0) {
const lastIdx = data.length - 1;
this.svg.append('circle')
.attr('cx', xScale(lastIdx))
.attr('cy', yScale(data[lastIdx]))
.attr('r', 2.5)
.attr('fill', sparkColor);
}
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-stat-card.component';

View File

@@ -0,0 +1,17 @@
<div class="viz-stat-card">
@if (icon()) {
<div class="viz-stat-card__icon">{{ icon() }}</div>
}
<div class="viz-stat-card__content">
<span class="viz-stat-card__label">{{ label() }}</span>
<span class="viz-stat-card__value">{{ displayValue() }}</span>
@if (trend()) {
<span class="viz-stat-card__trend" [class]="trendClass()">
<span class="viz-stat-card__trend-icon">{{ trendIcon() }}</span>
@if (trendValue()) {
<span class="viz-stat-card__trend-value">{{ trendValue() }}</span>
}
</span>
}
</div>
</div>

View File

@@ -0,0 +1,61 @@
:host {
display: block;
}
.viz-stat-card {
display: flex;
align-items: center;
gap: var(--viz-spacing-md);
background: var(--viz-bg);
border: 1px solid var(--viz-border);
border-radius: var(--viz-radius-md);
padding: var(--viz-spacing-lg);
&__icon {
font-size: 1.5rem;
flex-shrink: 0;
}
&__content {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: var(--viz-font-size-sm);
color: var(--viz-text-muted);
font-weight: 500;
}
&__value {
font-size: 1.75rem;
font-weight: 700;
color: var(--viz-text);
line-height: 1.2;
}
&__trend {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: var(--viz-font-size-sm);
font-weight: 500;
&--up {
color: var(--viz-stat-positive);
}
&--down {
color: var(--viz-stat-negative);
}
&--flat {
color: var(--viz-stat-neutral);
}
}
&__trend-icon {
font-weight: 700;
}
}

View File

@@ -0,0 +1,40 @@
import {
Component, ChangeDetectionStrategy, input, computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'viz-stat-card',
standalone: true,
imports: [CommonModule],
templateUrl: './viz-stat-card.component.html',
styleUrl: './viz-stat-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizStatCardComponent {
readonly value = input.required<string | number>();
readonly label = input.required<string>();
readonly trend = input<'up' | 'down' | 'flat' | undefined>(undefined);
readonly trendValue = input<string>('');
readonly icon = input<string>('');
readonly prefix = input<string>('');
readonly suffix = input<string>('');
readonly trendClass = computed(() => {
const t = this.trend();
if (t === 'up') return 'viz-stat-card__trend--up';
if (t === 'down') return 'viz-stat-card__trend--down';
return 'viz-stat-card__trend--flat';
});
readonly trendIcon = computed(() => {
const t = this.trend();
if (t === 'up') return '\u2191';
if (t === 'down') return '\u2193';
return '\u2192';
});
readonly displayValue = computed(() => {
return `${this.prefix()}${this.value()}${this.suffix()}`;
});
}

View File

@@ -0,0 +1 @@
export * from './viz-time-series.component';

View File

@@ -0,0 +1,9 @@
<div class="viz-time-series" [class.viz-time-series--legend-left]="legend().visible && legend().position === 'left'" [class.viz-time-series--legend-right]="legend().visible && legend().position === 'right'">
@if (legend().visible && (legend().position === 'top' || legend().position === 'left')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
<div #chart></div>
@if (legend().visible && (legend().position === 'bottom' || legend().position === 'right')) {
<viz-legend [items]="legendItems()" [position]="legend().position" (itemClick)="onLegendClick($event)" />
}
</div>

View File

@@ -0,0 +1,29 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-time-series {
display: flex;
flex-direction: column;
width: 100%;
&--legend-left,
&--legend-right {
flex-direction: row;
}
> div:not(.viz-legend) {
flex: 1;
min-width: 0;
}
::ng-deep {
.viz-time-series-svg { display: block; overflow: visible; }
.domain { stroke: var(--viz-axis-color); }
.tick line { stroke: var(--viz-tick-color); }
.tick text { fill: var(--viz-text-muted); font-size: var(--viz-font-size-xs); }
.grid-line { stroke: var(--viz-grid-color); stroke-dasharray: 2, 2; }
}
}

View File

@@ -0,0 +1,227 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect, computed, signal,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import { scaleTime, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { line, area, curveMonotoneX } from 'd3-shape';
import { extent, max } 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 { CartesianDataPoint, ChartSeries } from '../../types/chart.types';
import type { ChartMargin, AxisConfig, LegendConfig } from '../../types/config.types';
import type { ChartHoverEvent } from '../../types/event.types';
import { withOpacity } from '../../utils/color.utils';
import { VizLegendComponent, LegendItem } from '../viz-legend/viz-legend.component';
@Component({
selector: 'viz-time-series',
standalone: true,
imports: [VizLegendComponent],
templateUrl: './viz-time-series.component.html',
styleUrl: './viz-time-series.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizTimeSeriesComponent implements OnDestroy {
readonly series = input.required<ChartSeries[]>();
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 legend = input<LegendConfig>({ visible: true, position: 'top' });
readonly animate = input<boolean | undefined>(undefined);
readonly timeWindow = input<number | undefined>(undefined);
readonly autoScroll = input(true);
readonly brushEnabled = input(false);
readonly showArea = input(true);
readonly pointHover = output<ChartHoverEvent<CartesianDataPoint>>();
readonly hiddenItems = signal<Set<string>>(new Set());
readonly legendItems = computed(() =>
this.series().map((s, i) => ({
label: s.name,
color: s.color ?? this.themeService.getColor(i),
active: !this.hiddenItems().has(s.name),
}))
);
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.series();
const __ = this.hiddenItems();
this.ngZone.runOutsideAngular(() => {
if (!this.svg) {
this.createChart();
this.setupResize();
} else {
this.updateChart();
}
});
});
}
onLegendClick(event: { item: LegendItem; index: number }): void {
const hidden = new Set(this.hiddenItems());
if (hidden.has(event.item.label)) {
hidden.delete(event.item.label);
} else {
hidden.add(event.item.label);
}
this.hiddenItems.set(hidden);
}
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-time-series-svg');
this.svg.append('defs').append('clipPath').attr('id', 'ts-clip').append('rect');
this.svg.append('g').attr('class', 'x-axis');
this.svg.append('g').attr('class', 'y-axis');
this.svg.append('g').attr('class', 'chart-area').attr('clip-path', 'url(#ts-clip)');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const allSeries = this.series().filter(s => s.visible !== false && !this.hiddenItems().has(s.name));
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 yAxisConfig = this.yAxis();
this.svg.attr('width', w).attr('height', h);
// Clip path
this.svg.select('#ts-clip rect')
.attr('width', innerW)
.attr('height', innerH);
const allPoints: CartesianDataPoint[] = [];
for (const s of allSeries) { for (const d of s.data) { allPoints.push(d); } }
if (allPoints.length === 0) return;
let timeDomain = extent(allPoints, (d: CartesianDataPoint) => d.x as Date) as [Date, Date];
// Apply time window
const tw = this.timeWindow();
if (tw && this.autoScroll()) {
const latestTime = timeDomain[1].getTime();
timeDomain = [new Date(latestTime - tw), timeDomain[1]];
}
const xScale = scaleTime().domain(timeDomain).range([0, innerW]);
const yScale = scaleLinear()
.domain([yAxisConfig.min ?? 0, yAxisConfig.max ?? (max(allPoints, (d: CartesianDataPoint) => d.y) ?? 0)])
.range([innerH, 0])
.nice();
// Axes
this.svg.select<SVGGElement>('.x-axis')
.attr('transform', `translate(${m.left},${m.top + innerH})`)
.transition().duration(duration)
.call(axisBottom(xScale) as any);
const yAxisG = this.svg.select<SVGGElement>('.y-axis')
.attr('transform', `translate(${m.left},${m.top})`)
.call(axisLeft(yScale).ticks(yAxisConfig.tickCount ?? 5));
if (yAxisConfig.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 chartArea = this.svg.select('.chart-area')
.attr('transform', `translate(${m.left},${m.top})`);
const lineGen = line<CartesianDataPoint>()
.x(d => xScale(d.x as Date))
.y(d => yScale(d.y))
.curve(curveMonotoneX);
const areaGen = area<CartesianDataPoint>()
.x(d => xScale(d.x as Date))
.y0(innerH)
.y1(d => yScale(d.y))
.curve(curveMonotoneX);
// Render each series
allSeries.forEach((s, i) => {
const color = s.color ?? this.themeService.getColor(i);
const seriesClass = `series-${i}`;
// Area
if (this.showArea()) {
let areaPath = chartArea.select<SVGPathElement>(`.area-${seriesClass}`);
if (areaPath.empty()) {
areaPath = chartArea.append('path').attr('class', `area-${seriesClass}`);
}
areaPath
.transition().duration(duration)
.attr('d', areaGen(s.data) ?? '')
.attr('fill', withOpacity(color, 0.1));
}
// Line
let linePath = chartArea.select<SVGPathElement>(`.line-${seriesClass}`);
if (linePath.empty()) {
linePath = chartArea.append('path')
.attr('class', `line-${seriesClass}`)
.attr('fill', 'none')
.attr('stroke-width', 2);
}
linePath
.transition().duration(duration)
.attr('d', lineGen(s.data) ?? '')
.attr('stroke', color);
});
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-treemap.component';

View File

@@ -0,0 +1 @@
<div class="viz-treemap" #chart></div>

View File

@@ -0,0 +1,19 @@
:host {
display: block;
position: relative;
width: 100%;
}
.viz-treemap {
width: 100%;
::ng-deep {
.viz-treemap-svg { display: block; overflow: visible; }
rect {
cursor: pointer;
transition: opacity var(--viz-transition);
&:hover { opacity: 1; }
}
}
}

View File

@@ -0,0 +1,182 @@
import {
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
inject, input, output, viewChild, effect,
} from '@angular/core';
import { select, Selection } from 'd3-selection';
import {
treemap, hierarchy, treemapSquarify, treemapBinary,
treemapSlice, treemapDice, HierarchyRectangularNode,
} from 'd3-hierarchy';
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 { TreemapNode } from '../../types/chart.types';
import type { ChartMargin } from '../../types/config.types';
import type { ChartClickEvent, ChartHoverEvent } from '../../types/event.types';
const TILINGS: Record<string, typeof treemapSquarify> = {
squarify: treemapSquarify,
binary: treemapBinary as any,
slice: treemapSlice as any,
dice: treemapDice as any,
};
@Component({
selector: 'viz-treemap',
standalone: true,
templateUrl: './viz-treemap.component.html',
styleUrl: './viz-treemap.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizTreemapComponent implements OnDestroy {
readonly data = input.required<TreemapNode>();
readonly width = input<number | 'auto'>('auto');
readonly height = input<number>(400);
readonly margin = input<ChartMargin>({ top: 10, right: 10, bottom: 10, left: 10 });
readonly animate = input<boolean | undefined>(undefined);
readonly tiling = input<'squarify' | 'binary' | 'slice' | 'dice'>('squarify');
readonly showLabels = input(true);
readonly nodeClick = output<ChartClickEvent<TreemapNode>>();
readonly nodeHover = output<ChartHoverEvent<TreemapNode>>();
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.data();
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-treemap-svg');
this.svg.append('g').attr('class', 'nodes');
this.updateChart();
}
private updateChart(): void {
if (!this.svg) return;
const data = this.data();
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 tilingFn = TILINGS[this.tiling()] ?? treemapSquarify;
this.svg.attr('width', w).attr('height', h);
const root = hierarchy(data)
.sum(d => d.value ?? 0)
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
const treemapLayout = treemap<TreemapNode>()
.size([innerW, innerH])
.padding(2)
.tile(tilingFn);
treemapLayout(root);
const leaves = root.leaves() as HierarchyRectangularNode<TreemapNode>[];
const nodesG = this.svg.select('.nodes').attr('transform', `translate(${m.left},${m.top})`);
const groups = nodesG.selectAll<SVGGElement, HierarchyRectangularNode<TreemapNode>>('g')
.data(leaves, (d: HierarchyRectangularNode<TreemapNode>) => d.data.name);
groups.exit().transition().duration(duration).attr('opacity', 0).remove();
const enter = groups.enter().append('g').attr('opacity', 0);
enter.append('rect');
enter.append('text');
enter.append('clipPath').attr('id', (_, i) => `clip-treemap-${i}`).append('rect');
const merged = enter.merge(groups);
merged.transition().duration(duration).attr('opacity', 1);
merged.select('rect')
.on('mouseenter', (event: MouseEvent, d) => {
if (this.config.tooltips) {
this.tooltipService.show(event.clientX, event.clientY,
`<strong>${d.data.name}</strong>: ${d.value}`);
}
this.ngZone.run(() => this.nodeHover.emit({ data: d.data, index: leaves.indexOf(d), event }));
})
.on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY))
.on('mouseleave', (event: MouseEvent) => {
this.tooltipService.hide();
this.ngZone.run(() => this.nodeHover.emit({ data: null, index: -1, event }));
})
.on('click', (event: MouseEvent, d) => {
this.ngZone.run(() => this.nodeClick.emit({ data: d.data, index: leaves.indexOf(d), event }));
})
.transition()
.duration(duration)
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.attr('fill', (d, i) => d.data.color ?? this.themeService.getColor(i))
.attr('rx', 3)
.attr('opacity', 0.85);
if (this.showLabels()) {
merged.select('text')
.transition()
.duration(duration)
.attr('x', d => d.x0 + 4)
.attr('y', d => d.y0 + 14)
.attr('fill', '#fff')
.attr('font-size', 'var(--viz-font-size-xs, 12px)')
.attr('font-weight', '500')
.text(d => {
const nodeW = d.x1 - d.x0;
const nodeH = d.y1 - d.y0;
return nodeW > 40 && nodeH > 20 ? d.data.name : '';
});
}
}
private setupResize(): void {
if (this.width() === 'auto' && this.config.responsive) {
this.resizeCleanup = this.resizeService.observe(
this.chartRef().nativeElement,
() => this.updateChart(),
);
}
}
}

View File

@@ -0,0 +1 @@
export * from './viz-trend-indicator.component';

View File

@@ -0,0 +1,9 @@
<span class="viz-trend" [class]="directionClass()">
<span class="viz-trend__icon">{{ icon() }}</span>
@if (value()) {
<span class="viz-trend__value">{{ value() }}</span>
}
@if (label()) {
<span class="viz-trend__label">{{ label() }}</span>
}
</span>

View File

@@ -0,0 +1,36 @@
:host {
display: inline-flex;
}
.viz-trend {
display: inline-flex;
align-items: center;
gap: var(--viz-spacing-xs);
font-size: var(--viz-font-size-sm);
font-weight: 500;
&--up {
color: var(--viz-stat-positive);
}
&--down {
color: var(--viz-stat-negative);
}
&--flat {
color: var(--viz-stat-neutral);
}
&__icon {
font-size: 0.625rem;
}
&__value {
font-weight: 600;
}
&__label {
color: var(--viz-text-muted);
font-weight: 400;
}
}

View File

@@ -0,0 +1,25 @@
import {
Component, ChangeDetectionStrategy, input, computed,
} from '@angular/core';
@Component({
selector: 'viz-trend-indicator',
standalone: true,
templateUrl: './viz-trend-indicator.component.html',
styleUrl: './viz-trend-indicator.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VizTrendIndicatorComponent {
readonly direction = input.required<'up' | 'down' | 'flat'>();
readonly value = input<string>('');
readonly label = input<string>('');
readonly icon = computed(() => {
const d = this.direction();
if (d === 'up') return '\u25B2';
if (d === 'down') return '\u25BC';
return '\u25C6';
});
readonly directionClass = computed(() => `viz-trend--${this.direction()}`);
}

48
src/index.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Data Viz Elements UI
* Main library entry point
*
* Angular components for data visualization, charts, and dashboard widgets powered by D3.js
*
* @example
* ```typescript
* import { Component } from '@angular/core';
* import { VizBarChartComponent, provideVizConfig } from '@sda/data-viz-elements-ui';
*
* @Component({
* standalone: true,
* imports: [VizBarChartComponent],
* template: `
* <viz-bar-chart
* [data]="data"
* (barClick)="onBarClick($event)">
* </viz-bar-chart>
* `
* })
* export class AppComponent {
* data = [
* { label: 'A', value: 10 },
* { label: 'B', value: 20 },
* ];
*
* onBarClick(event: any) {
* console.log('Bar clicked:', event);
* }
* }
* ```
*/
// Types
export * from './types';
// Utils
export * from './utils';
// Providers
export * from './providers';
// Services
export * from './services';
// Components
export * from './components';

1
src/providers/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './viz-config.provider';

View File

@@ -0,0 +1,23 @@
import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders } from '@angular/core';
import { VizConfig } from '../types/config.types';
export const DEFAULT_VIZ_CONFIG: VizConfig = {
colorPalette: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#f97316', '#ec4899'],
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
animate: true,
animationDuration: 300,
responsive: true,
locale: 'en-US',
tooltips: true,
};
export const VIZ_CONFIG = new InjectionToken<VizConfig>('VIZ_CONFIG', {
providedIn: 'root',
factory: () => DEFAULT_VIZ_CONFIG,
});
export function provideVizConfig(config: Partial<VizConfig> = {}): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: VIZ_CONFIG, useValue: { ...DEFAULT_VIZ_CONFIG, ...config } },
]);
}

4
src/services/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './viz-theme.service';
export * from './viz-resize.service';
export * from './viz-tooltip.service';
export * from './viz-export.service';

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class VizExportService {
exportSvg(element: SVGSVGElement): string {
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(element);
return `<?xml version="1.0" encoding="UTF-8"?>\n${svgString}`;
}
async exportPng(element: SVGSVGElement, width: number, height: number): Promise<Blob> {
const svgString = this.exportSvg(element);
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
try {
const img = new Image();
img.width = width;
img.height = height;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create PNG blob'));
}
}, 'image/png');
});
} finally {
URL.revokeObjectURL(url);
}
}
downloadSvg(element: SVGSVGElement, filename = 'chart.svg'): void {
const svgString = this.exportSvg(element);
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
this.downloadBlob(blob, filename);
}
async downloadPng(element: SVGSVGElement, width: number, height: number, filename = 'chart.png'): Promise<void> {
const blob = await this.exportPng(element, width, height);
this.downloadBlob(blob, filename);
}
private downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable, NgZone, inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class VizResizeService {
private readonly ngZone = inject(NgZone);
private observer: ResizeObserver | null = null;
private readonly callbacks = new Map<Element, (entry: ResizeObserverEntry) => void>();
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
observe(element: Element, callback: (entry: ResizeObserverEntry) => void): () => void {
if (!this.observer) {
this.observer = new ResizeObserver((entries) => {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.ngZone.runOutsideAngular(() => {
for (const entry of entries) {
const cb = this.callbacks.get(entry.target);
cb?.(entry);
}
});
}, 150);
});
}
this.callbacks.set(element, callback);
this.observer.observe(element);
return () => {
this.callbacks.delete(element);
this.observer?.unobserve(element);
if (this.callbacks.size === 0) {
this.observer?.disconnect();
this.observer = null;
}
};
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { VIZ_CONFIG } from '../providers/viz-config.provider';
export const PALETTES: Record<string, string[]> = {
default: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#f97316', '#ec4899'],
categorical: ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#06b6d4', '#eab308', '#ef4444'],
sequential: ['#eff6ff', '#bfdbfe', '#93c5fd', '#60a5fa', '#3b82f6', '#2563eb', '#1d4ed8', '#1e40af'],
diverging: ['#ef4444', '#f87171', '#fca5a5', '#f3f4f6', '#93c5fd', '#60a5fa', '#3b82f6', '#2563eb'],
};
@Injectable({ providedIn: 'root' })
export class VizThemeService {
private readonly config = inject(VIZ_CONFIG);
private readonly paletteSignal = signal<string[]>(this.config.colorPalette);
readonly palette = this.paletteSignal.asReadonly();
readonly paletteSize = computed(() => this.paletteSignal().length);
getColor(index: number): string {
const p = this.paletteSignal();
return p[index % p.length];
}
setPalette(palette: string[] | keyof typeof PALETTES): void {
if (typeof palette === 'string') {
this.paletteSignal.set(PALETTES[palette] ?? PALETTES['default']);
} else {
this.paletteSignal.set(palette);
}
}
}

View File

@@ -0,0 +1,74 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class VizTooltipService {
private tooltipEl: HTMLDivElement | null = null;
private ensureElement(): HTMLDivElement {
if (!this.tooltipEl) {
this.tooltipEl = document.createElement('div');
this.tooltipEl.className = 'viz-tooltip';
this.tooltipEl.style.cssText = `
position: fixed;
pointer-events: none;
z-index: 9999;
padding: 6px 10px;
background: var(--viz-tooltip-bg, #1f2937);
color: var(--viz-tooltip-text, #f9fafb);
border-radius: 4px;
box-shadow: var(--viz-tooltip-shadow, 0 4px 12px rgba(0,0,0,0.15));
font-size: 0.875rem;
white-space: nowrap;
opacity: 0;
transition: opacity 150ms ease-in-out;
`;
document.body.appendChild(this.tooltipEl);
}
return this.tooltipEl;
}
show(x: number, y: number, content: string): void {
const el = this.ensureElement();
el.innerHTML = content;
el.style.opacity = '1';
this.position(el, x, y);
}
update(x: number, y: number): void {
if (this.tooltipEl) {
this.position(this.tooltipEl, x, y);
}
}
hide(): void {
if (this.tooltipEl) {
this.tooltipEl.style.opacity = '0';
}
}
private position(el: HTMLDivElement, x: number, y: number): void {
const offset = 10;
const rect = el.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + offset;
let top = y - rect.height - offset;
if (left + rect.width > viewportWidth) {
left = x - rect.width - offset;
}
if (top < 0) {
top = y + offset;
}
if (left < 0) {
left = offset;
}
if (top + rect.height > viewportHeight) {
top = viewportHeight - rect.height - offset;
}
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
}

2
src/styles/_index.scss Normal file
View File

@@ -0,0 +1,2 @@
@forward 'tokens';
@forward 'mixins';

58
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,58 @@
@mixin viz-chart-container {
display: block;
position: relative;
width: 100%;
font-family: var(--viz-font-family, inherit);
}
@mixin viz-svg-root {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
@mixin viz-axis {
.domain {
stroke: var(--viz-axis-color);
}
.tick {
line {
stroke: var(--viz-tick-color);
}
text {
fill: var(--viz-text-muted);
font-size: var(--viz-font-size-xs);
}
}
}
@mixin viz-grid-lines {
.grid-line {
stroke: var(--viz-grid-color);
stroke-dasharray: 2, 2;
}
}
@mixin viz-tooltip {
position: fixed;
pointer-events: none;
z-index: 9999;
padding: var(--viz-spacing-sm) var(--viz-spacing-md);
background: var(--viz-tooltip-bg);
color: var(--viz-tooltip-text);
border-radius: var(--viz-radius-md);
box-shadow: var(--viz-tooltip-shadow);
font-size: var(--viz-font-size-sm);
white-space: nowrap;
transition: opacity var(--viz-transition), transform var(--viz-transition);
}
@mixin viz-card {
background: var(--viz-bg);
border: 1px solid var(--viz-border);
border-radius: var(--viz-radius-md);
padding: var(--viz-spacing-lg);
}

67
src/styles/_tokens.scss Normal file
View File

@@ -0,0 +1,67 @@
:root {
// Chart colors (matching default palette)
--viz-color-1: #3b82f6;
--viz-color-2: #10b981;
--viz-color-3: #f59e0b;
--viz-color-4: #ef4444;
--viz-color-5: #8b5cf6;
--viz-color-6: #06b6d4;
--viz-color-7: #f97316;
--viz-color-8: #ec4899;
// Backgrounds
--viz-bg: #ffffff;
--viz-border: #e5e7eb;
--viz-text: #111827;
--viz-text-muted: #6b7280;
// Axes & grid
--viz-axis-color: #6b7280;
--viz-grid-color: #f3f4f6;
--viz-tick-color: #9ca3af;
// Tooltip
--viz-tooltip-bg: #1f2937;
--viz-tooltip-text: #f9fafb;
--viz-tooltip-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
// KPI widgets
--viz-stat-positive: #10b981;
--viz-stat-negative: #ef4444;
--viz-stat-neutral: #6b7280;
// Spacing
--viz-spacing-xs: 0.25rem;
--viz-spacing-sm: 0.5rem;
--viz-spacing-md: 0.75rem;
--viz-spacing-lg: 1rem;
// Radius
--viz-radius-sm: 0.25rem;
--viz-radius-md: 0.375rem;
// Typography
--viz-font-size-xs: 0.75rem;
--viz-font-size-sm: 0.875rem;
--viz-font-size-base: 1rem;
// Transition
--viz-transition: 200ms ease-in-out;
}
@media (prefers-color-scheme: dark) {
:root {
--viz-bg: #111827;
--viz-border: #374151;
--viz-text: #f9fafb;
--viz-text-muted: #9ca3af;
--viz-axis-color: #9ca3af;
--viz-grid-color: #1f2937;
--viz-tick-color: #6b7280;
--viz-tooltip-bg: #f9fafb;
--viz-tooltip-text: #111827;
--viz-tooltip-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
}

71
src/types/chart.types.ts Normal file
View File

@@ -0,0 +1,71 @@
/** Single data point for categorical charts (bar, pie) */
export interface ChartDataPoint {
label: string;
value: number;
color?: string;
metadata?: Record<string, unknown>;
}
/** Data point for continuous axes (line, area, scatter, time-series) */
export interface CartesianDataPoint {
x: number | Date;
y: number;
metadata?: Record<string, unknown>;
}
/** A named series of data points */
export interface ChartSeries<T = CartesianDataPoint> {
name: string;
data: T[];
color?: string;
visible?: boolean;
}
/** Scatter/bubble point with optional size */
export interface ScatterDataPoint extends CartesianDataPoint {
size?: number;
label?: string;
}
/** Heatmap cell */
export interface HeatmapCell {
x: string | number;
y: string | number;
value: number;
}
/** Box plot statistics */
export interface BoxPlotData {
label: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
outliers?: number[];
}
/** Treemap node */
export interface TreemapNode {
name: string;
value?: number;
children?: TreemapNode[];
color?: string;
}
/** Table column definition */
export interface TableColumn<T = unknown> {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
format?: (value: unknown, row: T) => string;
}
/** Table sort state */
export interface TableSort {
column: string;
direction: 'asc' | 'desc';
}

40
src/types/config.types.ts Normal file
View File

@@ -0,0 +1,40 @@
/** Global library configuration */
export interface VizConfig {
colorPalette: string[];
fontFamily: string;
animate: boolean;
animationDuration: number;
responsive: boolean;
locale: string;
tooltips: boolean;
}
/** Shared chart margin options */
export interface ChartMargin {
top: number;
right: number;
bottom: number;
left: number;
}
/** Axis configuration */
export interface AxisConfig {
label?: string;
tickCount?: number;
tickFormat?: (value: unknown) => string;
gridLines?: boolean;
min?: number;
max?: number;
}
/** Legend configuration */
export interface LegendConfig {
visible: boolean;
position: 'top' | 'bottom' | 'left' | 'right';
}
/** Tooltip configuration */
export interface TooltipConfig {
enabled: boolean;
format?: (data: unknown) => string;
}

25
src/types/event.types.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface ChartClickEvent<T = unknown> {
data: T;
index: number;
event: MouseEvent;
}
export interface ChartHoverEvent<T = unknown> {
data: T | null;
index: number;
event: MouseEvent;
}
export interface ChartSelectionEvent<T = unknown> {
selected: T[];
}
export interface TablePageEvent {
page: number;
pageSize: number;
}
export interface TableSortEvent {
column: string;
direction: 'asc' | 'desc';
}

3
src/types/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './chart.types';
export * from './config.types';
export * from './event.types';

24
src/utils/color.utils.ts Normal file
View File

@@ -0,0 +1,24 @@
import { interpolateRgb } from 'd3-interpolate';
import { color as d3Color } from 'd3-color';
export function lighten(hex: string, amount: number): string {
return interpolateRgb(hex, '#ffffff')(amount);
}
export function darken(hex: string, amount: number): string {
return interpolateRgb(hex, '#000000')(amount);
}
export function withOpacity(hex: string, opacity: number): string {
const c = d3Color(hex);
if (c) {
c.opacity = opacity;
return c.formatRgb();
}
return hex;
}
export function interpolateColors(colorA: string, colorB: string, steps: number): string[] {
const interpolator = interpolateRgb(colorA, colorB);
return Array.from({ length: steps }, (_, i) => interpolator(i / (steps - 1)));
}

26
src/utils/format.utils.ts Normal file
View File

@@ -0,0 +1,26 @@
import { format as d3Format } from 'd3-format';
import { timeFormat } from 'd3-time-format';
export function formatNumber(value: number, specifier = ',.0f'): string {
return d3Format(specifier)(value);
}
export function formatPercent(value: number, decimals = 1): string {
return d3Format(`.${decimals}%`)(value);
}
export function formatCompact(value: number): string {
return d3Format('.2s')(value);
}
export function formatDate(date: Date, pattern = '%b %d, %Y'): string {
return timeFormat(pattern)(date);
}
export function formatTime(date: Date, pattern = '%H:%M'): string {
return timeFormat(pattern)(date);
}
export function formatDateTime(date: Date, pattern = '%b %d %H:%M'): string {
return timeFormat(pattern)(date);
}

3
src/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './scale.utils';
export * from './format.utils';
export * from './color.utils';

47
src/utils/scale.utils.ts Normal file
View File

@@ -0,0 +1,47 @@
import { scaleLinear, scaleBand, scaleTime, scaleOrdinal, scaleSequential } from 'd3-scale';
import { min, max, extent } from 'd3-array';
import type { AxisConfig } from '../types/config.types';
export function createLinearScale(
data: number[],
range: [number, number],
axisConfig?: AxisConfig,
) {
const domain: [number, number] = [
axisConfig?.min ?? min(data) ?? 0,
axisConfig?.max ?? max(data) ?? 0,
];
return scaleLinear().domain(domain).range(range).nice();
}
export function createBandScale(
labels: string[],
range: [number, number],
padding = 0.2,
) {
return scaleBand<string>().domain(labels).range(range).padding(padding);
}
export function createTimeScale(
dates: Date[],
range: [number, number],
) {
const [minDate, maxDate] = extent(dates);
return scaleTime()
.domain([minDate ?? new Date(), maxDate ?? new Date()])
.range(range);
}
export function createOrdinalScale(
domain: string[],
colors: string[],
) {
return scaleOrdinal<string>().domain(domain).range(colors);
}
export function createSequentialScale(
domain: [number, number],
interpolator: (t: number) => string,
) {
return scaleSequential(interpolator).domain(domain);
}

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}