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 shadcn@latest add https://uipkge.dev/r/lit/analytics-overview.json
Manifest:https://uipkge.dev/r/lit/analytics-overview.jsonInstalls to:src/uip/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
}
BlockTheme
interface BlockTheme {
  colors: string[]
  textColor: string
  axisColor: string
  splitLineColor: string
  tooltipBg: string
  tooltipBorder: string
  tooltipText: string
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-analytics-overview': UipAnalyticsOverview
  }
}

npm dependencies

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 (1)

  • src/uip/blocks/analytics-overview/analytics-overview.ts14.7 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    // Side-effect import: registers <uip-raw-chart> (used for the drill-down pane)
    // for standalone use — and is what the registry build scans for dependencies.
    import '../../components/charts/raw-chart'
    
    // 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.
    
    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 },
    ]
    
    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],
    }
    
    interface BlockTheme {
      colors: string[]
      textColor: string
      axisColor: string
      splitLineColor: string
      tooltipBg: string
      tooltipBorder: string
      tooltipText: string
    }
    
    const FALLBACK_THEME: BlockTheme = {
      colors: ['#888888', '#888888', '#888888', '#888888', '#888888'],
      textColor: '#888888',
      axisColor: '#e5e5e5',
      splitLineColor: '#f0f0f0',
      tooltipBg: 'rgba(255,255,255,0.96)',
      tooltipBorder: '#e5e5e5',
      tooltipText: '#111111',
    }
    
    function readTheme(): BlockTheme {
      if (typeof window === 'undefined' || typeof getComputedStyle === 'undefined') return FALLBACK_THEME
      const cs = getComputedStyle(document.documentElement)
      const tok = (name: string, fallback: string) => cs?.getPropertyValue(name).trim() || fallback
      return {
        colors: [
          tok('--chart-1', FALLBACK_THEME.colors[0]!),
          tok('--chart-2', FALLBACK_THEME.colors[1]!),
          tok('--chart-3', FALLBACK_THEME.colors[2]!),
          tok('--chart-4', FALLBACK_THEME.colors[3]!),
          tok('--chart-5', FALLBACK_THEME.colors[4]!),
        ],
        textColor: tok('--muted-foreground', FALLBACK_THEME.textColor),
        axisColor: tok('--border', FALLBACK_THEME.axisColor),
        splitLineColor: tok('--border', FALLBACK_THEME.splitLineColor),
        tooltipBg: tok('--popover', FALLBACK_THEME.tooltipBg),
        tooltipBorder: tok('--border', FALLBACK_THEME.tooltipBorder),
        tooltipText: tok('--popover-foreground', FALLBACK_THEME.tooltipText),
      }
    }
    
    /**
     * <uip-analytics-overview> — the registry AnalyticsOverview block as a web component.
     *
     * Datasets, option builders, and classes are React's verbatim. React state
     * becomes { state: true } fields. The drill-down pane renders <uip-raw-chart>;
     * the overview combo (stacked bars + scatter overlay on a dual y-axis, outside
     * the opinionated wrappers' contract — React's documented escape hatch) is
     * rendered with echarts lazily imported in firstUpdated, so the element stays
     * SSR-safe. No props, no events. (React's metric/view selects are kept
     * verbatim even though the source never feeds them into the options.)
     */
    export class UipAnalyticsOverview extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        overviewMetric: { state: true },
        activeRoute: { state: true },
        drillView: { state: true },
        theme: { state: true },
      }
    
      private overviewMetric: 'overlay' | 'volume' = 'overlay'
      private activeRoute: string | null = null
      private drillView: 'Month' | 'Quarter' = 'Month'
      private theme: BlockTheme = FALLBACK_THEME
      private overviewChart: any = null
      private resizeObserver?: ResizeObserver
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'analytics-overview')
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        this.resizeObserver?.disconnect()
        this.resizeObserver = undefined
        try {
          this.overviewChart?.dispose()
        } catch {
          /* noop */
        }
        this.overviewChart = null
      }
    
      protected async firstUpdated() {
        this.theme = readTheme()
        const [core, renderers, charts, components] = await Promise.all([
          import('echarts/core'),
          import('echarts/renderers'),
          import('echarts/charts'),
          import('echarts/components'),
        ])
        core.use([
          renderers.CanvasRenderer,
          charts.BarChart,
          charts.ScatterChart,
          charts.LineChart,
          components.GridComponent,
          components.TooltipComponent,
          components.LegendComponent,
        ])
        const el = this.renderRoot.querySelector<HTMLDivElement>('[data-overview-chart]')
        if (!this.isConnected || !el) return
        this.overviewChart = core.init(el)
        this.overviewChart.setOption(this.buildOverviewOption(), { notMerge: true, lazyUpdate: true })
        this.overviewChart.on('click', (params: any) => {
          if (!params?.name) return
          const r = routes.find((rt) => rt.label === params.name)
          if (r) this.activeRoute = r.id
        })
        if (typeof ResizeObserver !== 'undefined') {
          this.resizeObserver = new ResizeObserver(() => this.overviewChart?.resize())
          this.resizeObserver.observe(el)
        }
      }
    
      protected updated(changed: Map<string, unknown>) {
        if (!this.overviewChart) return
        if (changed.has('theme')) {
          this.overviewChart.setOption(this.buildOverviewOption(), { notMerge: true, lazyUpdate: true })
          return
        }
        // Re-read tokens after any update so a light/dark flip repaints the
        // hand-rolled pane (the drill pane repaints itself via uip-raw-chart).
        const sig = JSON.stringify(readTheme())
        if (sig !== JSON.stringify(this.theme)) this.theme = JSON.parse(sig) as BlockTheme
      }
    
      private buildOverviewOption() {
        const theme = this.theme
        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],
        }
      }
    
      private buildDrillOption() {
        const theme = this.theme
        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,
        }
      }
    
      render() {
        const activeLabel = routes.find((r) => r.id === this.activeRoute)?.label ?? null
        return html`
          <div data-slot="analytics-overview" class="space-y-6">
            <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
                    class="border-border text-foreground ml-2 rounded-md border bg-transparent px-2 py-1 font-medium"
                    .value=${this.overviewMetric}
                    @change=${(e: Event) => {
                      this.overviewMetric = (e.target as HTMLSelectElement).value as 'overlay' | 'volume'
                    }}
                  >
                    <option value="overlay">Avg metric</option>
                    <option value="volume">Total volume</option>
                  </select>
                </div>
              </div>
              <div
                data-overview-chart
                role="img"
                tabindex="0"
                style="height:320px"
                class="focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none"
              ></div>
              <p class="text-muted-foreground mt-2 text-xs">Click a bar to drill into that route&apos;s monthly trend.</p>
            </section>
    
            <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">
                    ${activeLabel
                      ? html`<span class="text-muted-foreground font-normal">Drill-down:</span>
                          <span class="text-foreground ml-1">${activeLabel}</span>`
                      : html`<span class="text-muted-foreground font-normal">Select a route above to drill in</span>`}
                  </h3>
                  ${this.activeRoute
                    ? html`<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=${() => {
                          this.activeRoute = null
                        }}
                      >
                        ← Back to Overview
                      </button>`
                    : nothing}
                </div>
                <div class="text-muted-foreground text-xs">
                  <span>View By:</span>
                  <select
                    class="border-border text-foreground ml-2 rounded-md border bg-transparent px-2 py-1 font-medium"
                    .value=${this.drillView}
                    @change=${(e: Event) => {
                      this.drillView = (e.target as HTMLSelectElement).value as 'Month' | 'Quarter'
                    }}
                  >
                    <option value="Month">Month</option>
                    <option value="Quarter">Quarter</option>
                  </select>
                </div>
              </div>
              ${this.activeRoute
                ? html`<div
                    role="img"
                    tabindex="0"
                    style="height:280px"
                    class="focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none"
                  >
                    <uip-raw-chart .option=${this.buildDrillOption()} height="280"></uip-raw-chart>
                  </div>`
                : html`<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>
        `
      }
    }
    
    customElements.get('uip-analytics-overview') || customElements.define('uip-analytics-overview', UipAnalyticsOverview)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-analytics-overview': UipAnalyticsOverview
      }
    }
    

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