Menu

Change language

GitHub repo

Analytics Overview

blockanalytics

Two-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

$npx uipkge-ng@latest add analytics-overview
Manifest:https://uipkge.dev/r/angular/analytics-overview.jsonInstalls to:src/app/components/blocks/analytics-overview/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

Route
interface Route {
  id: string
  label: string
  volumes: Record<Partner, number>
  overlay: number
}

npm dependencies

Includes

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--accent-tint--accent-tint-soft

Files 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

Raw manifest:https://uipkge.dev/r/angular/analytics-overview.json