Initial commit: data-viz-elements-ui library
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.angular/
|
||||||
|
*.tgz
|
||||||
12
build-for-dev.sh
Executable file
12
build-for-dev.sh
Executable 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
21
ng-package.json
Normal 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
4083
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
package.json
Normal file
67
package.json
Normal 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
18
src/components/index.ts
Normal 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';
|
||||||
1
src/components/viz-area-chart/index.ts
Normal file
1
src/components/viz-area-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-area-chart.component';
|
||||||
@@ -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>
|
||||||
46
src/components/viz-area-chart/viz-area-chart.component.scss
Normal file
46
src/components/viz-area-chart/viz-area-chart.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/components/viz-area-chart/viz-area-chart.component.ts
Normal file
223
src/components/viz-area-chart/viz-area-chart.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-bar-chart/index.ts
Normal file
1
src/components/viz-bar-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-bar-chart.component';
|
||||||
@@ -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>
|
||||||
55
src/components/viz-bar-chart/viz-bar-chart.component.scss
Normal file
55
src/components/viz-bar-chart/viz-bar-chart.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/components/viz-bar-chart/viz-bar-chart.component.ts
Normal file
279
src/components/viz-bar-chart/viz-bar-chart.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-box-plot/index.ts
Normal file
1
src/components/viz-box-plot/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-box-plot.component';
|
||||||
1
src/components/viz-box-plot/viz-box-plot.component.html
Normal file
1
src/components/viz-box-plot/viz-box-plot.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="viz-box-plot" #chart></div>
|
||||||
17
src/components/viz-box-plot/viz-box-plot.component.scss
Normal file
17
src/components/viz-box-plot/viz-box-plot.component.scss
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/components/viz-box-plot/viz-box-plot.component.ts
Normal file
217
src/components/viz-box-plot/viz-box-plot.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-data-table/index.ts
Normal file
1
src/components/viz-data-table/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-data-table.component';
|
||||||
90
src/components/viz-data-table/viz-data-table.component.html
Normal file
90
src/components/viz-data-table/viz-data-table.component.html
Normal 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"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
149
src/components/viz-data-table/viz-data-table.component.scss
Normal file
149
src/components/viz-data-table/viz-data-table.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/components/viz-data-table/viz-data-table.component.ts
Normal file
122
src/components/viz-data-table/viz-data-table.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-gauge/index.ts
Normal file
1
src/components/viz-gauge/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-gauge.component';
|
||||||
1
src/components/viz-gauge/viz-gauge.component.html
Normal file
1
src/components/viz-gauge/viz-gauge.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="viz-gauge" #chart></div>
|
||||||
9
src/components/viz-gauge/viz-gauge.component.scss
Normal file
9
src/components/viz-gauge/viz-gauge.component.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viz-gauge {
|
||||||
|
::ng-deep {
|
||||||
|
.viz-gauge-svg { display: block; overflow: visible; }
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/components/viz-gauge/viz-gauge.component.ts
Normal file
238
src/components/viz-gauge/viz-gauge.component.ts
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-heatmap/index.ts
Normal file
1
src/components/viz-heatmap/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-heatmap.component';
|
||||||
1
src/components/viz-heatmap/viz-heatmap.component.html
Normal file
1
src/components/viz-heatmap/viz-heatmap.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="viz-heatmap" #chart></div>
|
||||||
19
src/components/viz-heatmap/viz-heatmap.component.scss
Normal file
19
src/components/viz-heatmap/viz-heatmap.component.scss
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/components/viz-heatmap/viz-heatmap.component.ts
Normal file
183
src/components/viz-heatmap/viz-heatmap.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-histogram/index.ts
Normal file
1
src/components/viz-histogram/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-histogram.component';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div class="viz-histogram" #chart></div>
|
||||||
27
src/components/viz-histogram/viz-histogram.component.scss
Normal file
27
src/components/viz-histogram/viz-histogram.component.scss
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/components/viz-histogram/viz-histogram.component.ts
Normal file
171
src/components/viz-histogram/viz-histogram.component.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
Component, ChangeDetectionStrategy, ElementRef, NgZone, OnDestroy,
|
||||||
|
inject, input, output, viewChild, effect,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { select, Selection } from 'd3-selection';
|
||||||
|
import { scaleLinear } from 'd3-scale';
|
||||||
|
import { axisBottom, axisLeft } from 'd3-axis';
|
||||||
|
import { bin, max, extent } from 'd3-array';
|
||||||
|
import 'd3-transition';
|
||||||
|
import { VIZ_CONFIG } from '../../providers/viz-config.provider';
|
||||||
|
import { VizThemeService } from '../../services/viz-theme.service';
|
||||||
|
import { VizResizeService } from '../../services/viz-resize.service';
|
||||||
|
import { VizTooltipService } from '../../services/viz-tooltip.service';
|
||||||
|
import type { ChartMargin, AxisConfig } from '../../types/config.types';
|
||||||
|
import type { ChartClickEvent } from '../../types/event.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'viz-histogram',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './viz-histogram.component.html',
|
||||||
|
styleUrl: './viz-histogram.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class VizHistogramComponent implements OnDestroy {
|
||||||
|
readonly values = input.required<number[]>();
|
||||||
|
readonly width = input<number | 'auto'>('auto');
|
||||||
|
readonly height = input<number>(300);
|
||||||
|
readonly margin = input<ChartMargin>({ top: 20, right: 20, bottom: 40, left: 50 });
|
||||||
|
readonly xAxis = input<AxisConfig>({});
|
||||||
|
readonly yAxis = input<AxisConfig>({ gridLines: true });
|
||||||
|
readonly animate = input<boolean | undefined>(undefined);
|
||||||
|
readonly bins = input<number>(20);
|
||||||
|
readonly density = input(false);
|
||||||
|
readonly color = input<string | undefined>(undefined);
|
||||||
|
|
||||||
|
readonly binClick = output<ChartClickEvent<{ x0: number; x1: number; count: number }>>();
|
||||||
|
|
||||||
|
private readonly chartRef = viewChild.required<ElementRef<HTMLDivElement>>('chart');
|
||||||
|
|
||||||
|
private readonly ngZone = inject(NgZone);
|
||||||
|
private readonly config = inject(VIZ_CONFIG);
|
||||||
|
private readonly themeService = inject(VizThemeService);
|
||||||
|
private readonly resizeService = inject(VizResizeService);
|
||||||
|
private readonly tooltipService = inject(VizTooltipService);
|
||||||
|
|
||||||
|
private svg: Selection<SVGSVGElement, unknown, null, undefined> | null = null;
|
||||||
|
private resizeCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const _ = this.values();
|
||||||
|
this.ngZone.runOutsideAngular(() => {
|
||||||
|
if (!this.svg) {
|
||||||
|
this.createChart();
|
||||||
|
this.setupResize();
|
||||||
|
} else {
|
||||||
|
this.updateChart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.resizeCleanup?.();
|
||||||
|
this.svg?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getWidth(): number {
|
||||||
|
const w = this.width();
|
||||||
|
return w === 'auto' ? (this.chartRef().nativeElement.clientWidth || 600) : w;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createChart(): void {
|
||||||
|
const el = this.chartRef().nativeElement;
|
||||||
|
this.svg = select(el).append('svg').attr('class', 'viz-histogram-svg');
|
||||||
|
this.svg.append('g').attr('class', 'x-axis');
|
||||||
|
this.svg.append('g').attr('class', 'y-axis');
|
||||||
|
this.svg.append('g').attr('class', 'bars');
|
||||||
|
this.updateChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateChart(): void {
|
||||||
|
if (!this.svg) return;
|
||||||
|
|
||||||
|
const values = this.values();
|
||||||
|
const w = this.getWidth();
|
||||||
|
const h = this.height();
|
||||||
|
const m = this.margin();
|
||||||
|
const innerW = w - m.left - m.right;
|
||||||
|
const innerH = h - m.top - m.bottom;
|
||||||
|
const shouldAnimate = this.animate() ?? this.config.animate;
|
||||||
|
const duration = shouldAnimate ? this.config.animationDuration : 0;
|
||||||
|
const barColor = this.color() ?? this.themeService.getColor(0);
|
||||||
|
|
||||||
|
this.svg.attr('width', w).attr('height', h);
|
||||||
|
|
||||||
|
const [minVal, maxVal] = extent(values) as [number, number];
|
||||||
|
const xScale = scaleLinear().domain([minVal, maxVal]).range([0, innerW]).nice();
|
||||||
|
|
||||||
|
const histogram = bin().domain(xScale.domain() as [number, number]).thresholds(this.bins());
|
||||||
|
const binsData = histogram(values);
|
||||||
|
|
||||||
|
const yScale = scaleLinear()
|
||||||
|
.domain([0, max(binsData, d => d.length) ?? 0])
|
||||||
|
.range([innerH, 0])
|
||||||
|
.nice();
|
||||||
|
|
||||||
|
this.svg.select<SVGGElement>('.x-axis')
|
||||||
|
.attr('transform', `translate(${m.left},${m.top + innerH})`)
|
||||||
|
.call(axisBottom(xScale));
|
||||||
|
|
||||||
|
const yAxisG = this.svg.select<SVGGElement>('.y-axis')
|
||||||
|
.attr('transform', `translate(${m.left},${m.top})`)
|
||||||
|
.call(axisLeft(yScale).ticks(this.yAxis().tickCount ?? 5));
|
||||||
|
|
||||||
|
if (this.yAxis().gridLines) {
|
||||||
|
yAxisG.selectAll('.grid-line').remove();
|
||||||
|
yAxisG.selectAll('.tick line')
|
||||||
|
.clone()
|
||||||
|
.attr('x2', innerW)
|
||||||
|
.attr('stroke', 'var(--viz-grid-color, #f3f4f6)')
|
||||||
|
.attr('stroke-dasharray', '2,2')
|
||||||
|
.attr('class', 'grid-line');
|
||||||
|
}
|
||||||
|
|
||||||
|
const barsG = this.svg.select('.bars').attr('transform', `translate(${m.left},${m.top})`);
|
||||||
|
|
||||||
|
const bars = barsG.selectAll<SVGRectElement, (typeof binsData)[0]>('rect')
|
||||||
|
.data(binsData);
|
||||||
|
|
||||||
|
bars.exit().transition().duration(duration).attr('height', 0).attr('y', innerH).remove();
|
||||||
|
|
||||||
|
bars.enter()
|
||||||
|
.append('rect')
|
||||||
|
.attr('y', innerH)
|
||||||
|
.attr('height', 0)
|
||||||
|
.attr('rx', 1)
|
||||||
|
.attr('fill', barColor)
|
||||||
|
.merge(bars)
|
||||||
|
.on('mouseenter', (event: MouseEvent, d) => {
|
||||||
|
if (this.config.tooltips) {
|
||||||
|
this.tooltipService.show(event.clientX, event.clientY,
|
||||||
|
`${d.x0?.toFixed(1)} – ${d.x1?.toFixed(1)}: <strong>${d.length}</strong>`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('mousemove', (event: MouseEvent) => this.tooltipService.update(event.clientX, event.clientY))
|
||||||
|
.on('mouseleave', () => this.tooltipService.hide())
|
||||||
|
.on('click', (event: MouseEvent, d) => {
|
||||||
|
this.ngZone.run(() => this.binClick.emit({
|
||||||
|
data: { x0: d.x0 ?? 0, x1: d.x1 ?? 0, count: d.length },
|
||||||
|
index: binsData.indexOf(d),
|
||||||
|
event,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.transition()
|
||||||
|
.duration(duration)
|
||||||
|
.attr('x', d => xScale(d.x0 ?? 0))
|
||||||
|
.attr('width', d => Math.max(0, xScale(d.x1 ?? 0) - xScale(d.x0 ?? 0) - 1))
|
||||||
|
.attr('y', d => yScale(d.length))
|
||||||
|
.attr('height', d => innerH - yScale(d.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupResize(): void {
|
||||||
|
if (this.width() === 'auto' && this.config.responsive) {
|
||||||
|
this.resizeCleanup = this.resizeService.observe(
|
||||||
|
this.chartRef().nativeElement,
|
||||||
|
() => this.updateChart(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-legend/index.ts
Normal file
1
src/components/viz-legend/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-legend.component';
|
||||||
18
src/components/viz-legend/viz-legend.component.html
Normal file
18
src/components/viz-legend/viz-legend.component.html
Normal 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>
|
||||||
59
src/components/viz-legend/viz-legend.component.scss
Normal file
59
src/components/viz-legend/viz-legend.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/components/viz-legend/viz-legend.component.ts
Normal file
43
src/components/viz-legend/viz-legend.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-line-chart/index.ts
Normal file
1
src/components/viz-line-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-line-chart.component';
|
||||||
@@ -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>
|
||||||
57
src/components/viz-line-chart/viz-line-chart.component.scss
Normal file
57
src/components/viz-line-chart/viz-line-chart.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
290
src/components/viz-line-chart/viz-line-chart.component.ts
Normal file
290
src/components/viz-line-chart/viz-line-chart.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-pie-chart/index.ts
Normal file
1
src/components/viz-pie-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-pie-chart.component';
|
||||||
@@ -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>
|
||||||
39
src/components/viz-pie-chart/viz-pie-chart.component.scss
Normal file
39
src/components/viz-pie-chart/viz-pie-chart.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/components/viz-pie-chart/viz-pie-chart.component.ts
Normal file
231
src/components/viz-pie-chart/viz-pie-chart.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-progress-bar/index.ts
Normal file
1
src/components/viz-progress-bar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-progress-bar.component';
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
1
src/components/viz-progress-ring/index.ts
Normal file
1
src/components/viz-progress-ring/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-progress-ring.component';
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
1
src/components/viz-scatter-chart/index.ts
Normal file
1
src/components/viz-scatter-chart/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-scatter-chart.component';
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/components/viz-scatter-chart/viz-scatter-chart.component.ts
Normal file
222
src/components/viz-scatter-chart/viz-scatter-chart.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-sparkline/index.ts
Normal file
1
src/components/viz-sparkline/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-sparkline.component';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<span class="viz-sparkline" #chart></span>
|
||||||
14
src/components/viz-sparkline/viz-sparkline.component.scss
Normal file
14
src/components/viz-sparkline/viz-sparkline.component.scss
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viz-sparkline {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
.viz-sparkline-svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/components/viz-sparkline/viz-sparkline.component.ts
Normal file
132
src/components/viz-sparkline/viz-sparkline.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-stat-card/index.ts
Normal file
1
src/components/viz-stat-card/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-stat-card.component';
|
||||||
17
src/components/viz-stat-card/viz-stat-card.component.html
Normal file
17
src/components/viz-stat-card/viz-stat-card.component.html
Normal 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>
|
||||||
61
src/components/viz-stat-card/viz-stat-card.component.scss
Normal file
61
src/components/viz-stat-card/viz-stat-card.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/components/viz-stat-card/viz-stat-card.component.ts
Normal file
40
src/components/viz-stat-card/viz-stat-card.component.ts
Normal 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()}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
1
src/components/viz-time-series/index.ts
Normal file
1
src/components/viz-time-series/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-time-series.component';
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/components/viz-time-series/viz-time-series.component.ts
Normal file
227
src/components/viz-time-series/viz-time-series.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-treemap/index.ts
Normal file
1
src/components/viz-treemap/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-treemap.component';
|
||||||
1
src/components/viz-treemap/viz-treemap.component.html
Normal file
1
src/components/viz-treemap/viz-treemap.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<div class="viz-treemap" #chart></div>
|
||||||
19
src/components/viz-treemap/viz-treemap.component.scss
Normal file
19
src/components/viz-treemap/viz-treemap.component.scss
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/components/viz-treemap/viz-treemap.component.ts
Normal file
182
src/components/viz-treemap/viz-treemap.component.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/viz-trend-indicator/index.ts
Normal file
1
src/components/viz-trend-indicator/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-trend-indicator.component';
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
48
src/index.ts
Normal 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
1
src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './viz-config.provider';
|
||||||
23
src/providers/viz-config.provider.ts
Normal file
23
src/providers/viz-config.provider.ts
Normal 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
4
src/services/index.ts
Normal 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';
|
||||||
66
src/services/viz-export.service.ts
Normal file
66
src/services/viz-export.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/services/viz-resize.service.ts
Normal file
39
src/services/viz-resize.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/services/viz-theme.service.ts
Normal file
33
src/services/viz-theme.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/services/viz-tooltip.service.ts
Normal file
74
src/services/viz-tooltip.service.ts
Normal 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
2
src/styles/_index.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@forward 'tokens';
|
||||||
|
@forward 'mixins';
|
||||||
58
src/styles/_mixins.scss
Normal file
58
src/styles/_mixins.scss
Normal 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
67
src/styles/_tokens.scss
Normal 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
71
src/types/chart.types.ts
Normal 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
40
src/types/config.types.ts
Normal 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
25
src/types/event.types.ts
Normal 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
3
src/types/index.ts
Normal 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
24
src/utils/color.utils.ts
Normal 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
26
src/utils/format.utils.ts
Normal 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
3
src/utils/index.ts
Normal 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
47
src/utils/scale.utils.ts
Normal 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
28
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user