Analytics Overview
blockanalyticsTwo-pane analytics dashboard: stacked bar with dot overlay on a dual y-axis above, and an interactive drill-down line chart below. Click a bar to filter the bottom pane to that route.
Installation
$pnpm dlx uipkge-ng@latest add analytics-overview$npx uipkge-ng@latest add analytics-overview$yarn dlx uipkge-ng@latest add analytics-overview$bunx uipkge-ng@latest add analytics-overviewManifest:
https://uipkge.dev/r/angular/analytics-overview.jsonInstalls to:src/app/components/blocks/analytics-overview/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Routeinterface Route {
id: string
label: string
volumes: Record<Partner, number>
overlay: number
}Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--accent-tint--accent-tint-softFiles installed (2)
src/app/components/blocks/analytics-overview/analytics-overview.component.ts14.5 kB
// Two-pane analytics dashboard: a combo chart (stacked bars with a // dot overlay on a dual y-axis) sits above an interactive drill-down // line chart. Click a bar in the top pane to filter the bottom pane // to that route. The block bakes in a realistic dummy dataset; swap // `routes`, `partners`, and `drillSeries` with your own to repoint. // // Built on raw ECharts rather than `ui-bar-chart` because combo charts // (bar + scatter + dual y-axes) sit outside the opinionated wrapper's // single-series-type contract -- this is the documented escape hatch. // We register the chart types this block needs at module load (mirroring // the wrappers in `@/ui/charts`) and read palette tokens from // `use-chart-theme` so the colours stay in sync with the rest of the registry. import { Component, DestroyRef, Directive, ElementRef, EventEmitter, Input, Output, afterRenderEffect, computed, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { init, use, type ECElementEvent, type EChartsCoreOption, type EChartsType } from 'echarts/core' import { CanvasRenderer } from 'echarts/renderers' import { BarChart as EBar, ScatterChart as EScatter, LineChart as ELine } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent, MarkLineComponent } from 'echarts/components' import { getChartAxisColor, getChartColors, getChartSplitLineColor, getChartTextColor, getChartTooltipBg, getChartTooltipBorder, getChartTooltipText, onChartThemeChange, } from '@/ui/charts/use-chart-theme' use([CanvasRenderer, EBar, EScatter, ELine, GridComponent, TooltipComponent, LegendComponent, MarkLineComponent]) // Each route declares the per-partner volume (stack segments) + the // avg overlay value (dot on the right y-axis). const partners = ['ALPHA', 'BETA', 'GAMMA', 'DELTA', 'EPSILON'] as const type Partner = (typeof partners)[number] interface Route { id: string label: string volumes: Record<Partner, number> overlay: number } const routes: Route[] = [ { id: 'r1', label: 'NODE A → NODE B', volumes: { ALPHA: 90, BETA: 25, GAMMA: 18, DELTA: 12, EPSILON: 8 }, overlay: 12, }, { id: 'r2', label: 'NODE C → NODE D', volumes: { ALPHA: 4, BETA: 100, GAMMA: 14, DELTA: 12, EPSILON: 24 }, overlay: 14.7, }, { id: 'r3', label: 'NODE E → NODE F', volumes: { ALPHA: 14, BETA: 38, GAMMA: 18, DELTA: 32, EPSILON: 28 }, overlay: 8, }, { id: 'r4', label: 'NODE G → NODE H', volumes: { ALPHA: 130, BETA: 0, GAMMA: 0, DELTA: 0, EPSILON: 0 }, overlay: 6 }, { id: 'r5', label: 'NODE I → NODE J', volumes: { ALPHA: 0, BETA: 0, GAMMA: 60, DELTA: 0, EPSILON: 70 }, overlay: 17 }, { id: 'r6', label: 'NODE K → NODE L', volumes: { ALPHA: 60, BETA: 50, GAMMA: 16, DELTA: 0, EPSILON: 0 }, overlay: 8 }, { id: 'r7', label: 'NODE M → NODE N', volumes: { ALPHA: 70, BETA: 0, GAMMA: 0, DELTA: 0, EPSILON: 0 }, overlay: 22 }, { id: 'r8', label: 'NODE O → NODE P', volumes: { ALPHA: 0, BETA: 0, GAMMA: 0, DELTA: 0, EPSILON: 110 }, overlay: 29 }, { id: 'r9', label: 'NODE Q → NODE R', volumes: { ALPHA: 40, BETA: 0, GAMMA: 60, DELTA: 0, EPSILON: 0 }, overlay: 19 }, { id: 'r10', label: 'NODE S → NODE T', volumes: { ALPHA: 60, BETA: 30, GAMMA: 0, DELTA: 0, EPSILON: 0 }, overlay: 23, }, ] // Drill-down monthly series for a few partners. In a real app you'd // fetch this per-route on selection; the dummy block shows the same // shape regardless of which route is active. const drillMonths = ['Mar 2026', 'Apr 2026', 'May 2026'] const drillSeries: Record<string, number[]> = { ALPHA: [22, 25, 20], GAMMA: [19, 17, 18], EPSILON: [-1, 29, -1], // sparse point — left out of line by NaN-like handling } export type AnalyticsOverviewMetric = 'overlay' | 'volume' export type AnalyticsOverviewDrillView = 'Month' | 'Quarter' /** The resolved chart theme tokens (canvas-safe colours), re-read on every theme flip. */ function chartTheme() { return { colors: getChartColors(), textColor: getChartTextColor(), axisColor: getChartAxisColor(), splitLineColor: getChartSplitLineColor(), tooltipBg: getChartTooltipBg(), tooltipBorder: getChartTooltipBorder(), tooltipText: getChartTooltipText(), } } /** * Mounts one ECharts instance on its host element (what `echarts-for-react` does for the * React block): applies the option with `notMerge`, resizes with the container, re-emits * ECharts `click` events and disposes the instance when the host leaves the DOM. */ @Directive({ selector: '[uiAnalyticsOverviewChart]', standalone: true }) export class UiAnalyticsOverviewChartDirective { /** The full ECharts option, applied with `notMerge` whenever it changes. */ @Input({ alias: 'uiAnalyticsOverviewChart', required: true }) set option(value: EChartsCoreOption) { this.optionState.set(value) } /** ECharts `click` events (a bar, a dot, a line point). */ @Output() readonly chartClick = new EventEmitter<ECElementEvent>() private readonly optionState = signal<EChartsCoreOption | null>(null) private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement private chart: EChartsType | null = null private resizeObserver: ResizeObserver | null = null constructor() { afterRenderEffect(() => { const option = this.optionState() if (!option) return if (!this.chart) { this.chart = init(this.el) this.chart.on('click', (params) => this.chartClick.emit(params as ECElementEvent)) if (typeof ResizeObserver !== 'undefined') { this.resizeObserver = new ResizeObserver(() => this.chart?.resize()) this.resizeObserver.observe(this.el) } } this.chart.setOption(option, { notMerge: true, lazyUpdate: true }) }) inject(DestroyRef).onDestroy(() => { this.resizeObserver?.disconnect() this.chart?.dispose() this.chart = null }) } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-analytics-overview, [ui-analytics-overview]', standalone: true, host: { class: 'block' }, imports: [UiAnalyticsOverviewChartDirective], template: ` <div data-slot="analytics-overview" class="space-y-6"> <!-- Overview pane --> <section class="bg-card border-border rounded-lg border p-5"> <div class="mb-3 flex items-start justify-between gap-4"> <div> <h2 class="text-sm font-semibold tracking-tight text-[var(--accent-tint)]">Overview</h2> <p class="text-muted-foreground mt-1 text-xs"> <span class="text-foreground font-medium">Metric</span> measures the avg performance signal aggregated per route. </p> </div> <div class="text-muted-foreground text-xs"> <span>Performance Metric:</span> <select [value]="overviewMetric()" (change)="setOverviewMetric($event)" class="border-border text-foreground ml-2 rounded-md border bg-transparent px-2 py-1 font-medium" > <option value="overlay">Avg metric</option> <option value="volume">Total volume</option> </select> </div> </div> <div role="img" tabindex="0" class="focus-visible:ring-ring h-[320px] w-full focus-visible:ring-2 focus-visible:outline-none" > <div class="size-full" [uiAnalyticsOverviewChart]="overviewOption()" (chartClick)="onOverviewClick($event)" ></div> </div> <p class="text-muted-foreground mt-2 text-xs">Click a bar to drill into that route's monthly trend.</p> </section> <!-- Drill-down pane --> <section class="bg-card border-border rounded-lg border p-5"> <div class="mb-3 flex items-center justify-between gap-4"> <div class="flex items-center gap-3"> <h3 class="text-sm font-semibold tracking-tight"> @if (activeLabel(); as label) { <span class="text-muted-foreground font-normal">Drill-down:</span> <span class="text-foreground ml-1">{{ label }}</span> } @else { <span class="text-muted-foreground font-normal">Select a route above to drill in</span> } </h3> @if (activeRoute()) { <button type="button" class="border-border text-muted-foreground hover:text-foreground rounded-md border px-2 py-1 font-mono text-xs transition hover:border-[var(--accent-tint-soft)]" (click)="resetDrill()" > ← Back to Overview </button> } </div> <div class="text-muted-foreground text-xs"> <span>View By:</span> <select [value]="drillView()" (change)="setDrillView($event)" class="border-border text-foreground ml-2 rounded-md border bg-transparent px-2 py-1 font-medium" > <option value="Month">Month</option> <option value="Quarter">Quarter</option> </select> </div> </div> @if (activeRoute()) { <div role="img" tabindex="0" class="focus-visible:ring-ring h-[280px] w-full focus-visible:ring-2 focus-visible:outline-none" > <div class="size-full" [uiAnalyticsOverviewChart]="drillOption()"></div> </div> } @else { <div class="text-muted-foreground border-border grid h-[260px] place-items-center rounded-md border border-dashed text-xs" > No route selected. Click a bar above. </div> } </section> </div> `, }) export class UiAnalyticsOverviewComponent { readonly overviewMetric = signal<AnalyticsOverviewMetric>('overlay') readonly activeRoute = signal<string | null>(null) readonly drillView = signal<AnalyticsOverviewDrillView>('Month') /** Bumped whenever `<html>` class/style flips, so the options re-read the theme tokens. */ private readonly themeTick = signal(0) readonly overviewOption = computed<EChartsCoreOption>(() => { this.themeTick() const theme = chartTheme() const x = routes.map((r) => r.label) const stacks = partners.map((p, i) => ({ name: p, type: 'bar' as const, stack: 'vol', barMaxWidth: 36, emphasis: { focus: 'series' as const }, itemStyle: { color: theme.colors[i % theme.colors.length] }, data: routes.map((r) => r.volumes[p]), })) const overlay = { name: 'Avg metric', type: 'scatter' as const, yAxisIndex: 1, symbolSize: 12, itemStyle: { color: theme.colors[2], borderColor: theme.tooltipBg, borderWidth: 2 }, data: routes.map((r) => r.overlay), } return { color: theme.colors, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, backgroundColor: theme.tooltipBg, borderColor: theme.tooltipBorder, textStyle: { color: theme.tooltipText, fontSize: 12 }, }, legend: { top: 8, icon: 'circle', itemWidth: 8, itemHeight: 8, textStyle: { color: theme.textColor, fontSize: 11 }, }, grid: { left: 56, right: 56, top: 42, bottom: 72, containLabel: false }, xAxis: { type: 'category' as const, data: x, axisLine: { lineStyle: { color: theme.axisColor } }, axisLabel: { interval: 0, rotate: 35, fontSize: 10, color: theme.textColor, formatter: (val: string) => val.replace(' → ', '\n'), }, axisTick: { show: false }, }, yAxis: [ { type: 'value' as const, name: 'Volume', nameTextStyle: { color: theme.textColor, fontSize: 10 }, splitLine: { lineStyle: { color: theme.splitLineColor } }, axisLabel: { color: theme.textColor, fontSize: 10 }, }, { type: 'value' as const, name: 'Metric', nameTextStyle: { color: theme.textColor, fontSize: 10 }, splitLine: { show: false }, axisLabel: { color: theme.textColor, fontSize: 10 }, }, ], series: [...stacks, overlay], } }) readonly drillOption = computed<EChartsCoreOption>(() => { this.themeTick() const theme = chartTheme() const series = Object.entries(drillSeries).map(([name, values], i) => ({ name, type: 'line' as const, smooth: true, symbolSize: 8, connectNulls: false, lineStyle: { width: 2, color: theme.colors[i % theme.colors.length] }, itemStyle: { color: theme.colors[i % theme.colors.length] }, data: values.map((v) => (v < 0 ? null : v)), })) return { color: theme.colors, tooltip: { trigger: 'axis', backgroundColor: theme.tooltipBg, borderColor: theme.tooltipBorder, textStyle: { color: theme.tooltipText, fontSize: 12 }, }, legend: { top: 8, icon: 'circle', itemWidth: 8, itemHeight: 8, textStyle: { color: theme.textColor, fontSize: 11 }, }, grid: { left: 48, right: 24, top: 42, bottom: 48, containLabel: false }, xAxis: { type: 'category' as const, data: drillMonths, boundaryGap: false, axisLine: { lineStyle: { color: theme.axisColor } }, axisLabel: { color: theme.textColor, fontSize: 11 }, axisTick: { show: false }, }, yAxis: { type: 'value' as const, splitLine: { lineStyle: { color: theme.splitLineColor } }, axisLabel: { color: theme.textColor, fontSize: 11, formatter: (v: number) => `${v}d` }, }, series, } }) readonly activeLabel = computed(() => routes.find((r) => r.id === this.activeRoute())?.label ?? null) constructor() { const unsubscribe = onChartThemeChange(() => this.themeTick.update((n) => n + 1)) inject(DestroyRef).onDestroy(unsubscribe) } onOverviewClick(params: ECElementEvent | null | undefined): void { if (!params?.name) return const r = routes.find((rt) => rt.label === params.name) if (r) this.activeRoute.set(r.id) } resetDrill(): void { this.activeRoute.set(null) } setOverviewMetric(event: Event): void { this.overviewMetric.set((event.target as HTMLSelectElement).value as AnalyticsOverviewMetric) } setDrillView(event: Event): void { this.drillView.set((event.target as HTMLSelectElement).value as AnalyticsOverviewDrillView) } }src/app/components/blocks/analytics-overview/index.ts0.2 kB
export { UiAnalyticsOverviewComponent, UiAnalyticsOverviewChartDirective, type AnalyticsOverviewMetric, type AnalyticsOverviewDrillView, } from './analytics-overview.component'
Raw manifest:https://uipkge.dev/r/angular/analytics-overview.json