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 shadcn@latest add https://uipkge.dev/r/lit/analytics-overview.json$npx shadcn@latest add https://uipkge.dev/r/lit/analytics-overview.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/analytics-overview.json$bunx shadcn@latest add https://uipkge.dev/r/lit/analytics-overview.jsonManifest:
https://uipkge.dev/r/lit/analytics-overview.jsonInstalls to:src/uip/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
}BlockThemeinterface BlockTheme {
colors: string[]
textColor: string
axisColor: string
splitLineColor: string
tooltipBg: string
tooltipBorder: string
tooltipText: string
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-analytics-overview': UipAnalyticsOverview
}
}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 (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'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