Menu

Change language

GitHub repo

Scatter Chart

scatter-chartui

Scatter chart wrapper around Apache ECharts. Value/value point cloud with configurable symbol size and shape. Theme-aware via registry tokens.

Installation

$npx uipkge-ng@latest add scatter-chart
Manifest:https://uipkge.dev/r/angular/scatter-chart.jsonInstalls to:src/app/components/ui/charts/scatter-chart/

Examples

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
dataRecord<string, unknown>[][]optional
xFieldstringxoptional
yFieldstringyoptional
sizeField

Numeric field driving bubble size. Unset = fixed `symbolSize`.

string—optional
categoryField

Categorical field splitting the data into one series per value.

string—optional
symbolSize

Symbol size in px when `sizeField` is unset. Default 10.

number10optional
symbol

Symbol shape. Default 'circle'.

stringcircleoptional
heightnumber | string300optional
optionRecord<string, unknown>—optional
ariaLabel

Accessible name announced for the chart image. Defaults to "Chart".

string—optional
classstring—optional

npm dependencies

Used by

Files installed (3)

  • src/app/components/ui/charts/scatter-chart/scatter-chart.component.ts7.1 kB
    import {
      AfterViewInit,
      Component,
      ElementRef,
      Input,
      OnChanges,
      OnDestroy,
      SimpleChanges,
      ViewChild,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import type * as echarts from 'echarts/core'
    import { cn } from '@/lib/utils'
    import {
      getChartAxisColor,
      getChartColors,
      getChartSplitLineColor,
      getChartTextColor,
      getChartTooltipBg,
      getChartTooltipBorder,
      getChartTooltipText,
      mergeOptionBlock,
    } from '../use-chart-theme'
    
    /**
     * Angular port of the UIPKGE ScatterChart — value/value point cloud
     * with configurable symbol size and shape. Standalone, theme-aware.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-scatter-chart, [ui-scatter-chart]',
      standalone: true,
      host: {
        '[attr.data-slot]': '"scatter-chart"',
        '[attr.data-uipkge]': '""',
        '[class]': 'hostClass',
        '[attr.role]': '"img"',
        '[attr.aria-label]': 'ariaLabel || "Chart"',
        '[attr.tabindex]': '0',
        '[style.height]': 'heightStyle',
      },
      template: `<div #chartEl class="size-full"></div>`,
    })
    export class UiScatterChartComponent implements AfterViewInit, OnChanges, OnDestroy {
      @Input() data: Record<string, unknown>[] = []
      @Input() xField = 'x'
      @Input() yField = 'y'
      /** Numeric field driving bubble size. Unset = fixed `symbolSize`. */
      @Input() sizeField?: string
      /** Categorical field splitting the data into one series per value. */
      @Input() categoryField?: string
      /** Symbol size in px when `sizeField` is unset. Default 10. */
      @Input() symbolSize = 10
      /** Symbol shape. Default 'circle'. */
      @Input() symbol = 'circle'
      @Input() height: number | string = 300
      @Input() option?: Record<string, unknown>
      /** Accessible name announced for the chart image. Defaults to "Chart". */
      @Input() ariaLabel?: string
      @Input('class') className?: string
    
      points(): [unknown, unknown][] {
        return this.data.map((d) => [d[this.xField], d[this.yField]])
      }
      @ViewChild('chartEl', { static: false }) chartEl?: ElementRef<HTMLDivElement>
    
      private chart: echarts.ECharts | null = null
      private unsubscribeTheme: (() => void) | null = null
    
      get hostClass(): string {
        return cn('block focus-visible:ring-ring w-full focus-visible:ring-2 focus-visible:outline-none', this.className)
      }
    
      get heightStyle(): string {
        return /^\d+$/.test(String(this.height)) ? `${this.height}px` : String(this.height)
      }
    
      /** One series per category (React/Vue parity), or a single default series. Pure. */
      categories(): unknown[] {
        return this.categoryField ? [...new Set(this.data.map((d) => d[this.categoryField!]))] : ['default']
      }
    
      /** Build the merged ECharts option (pure — no DOM needed, unit-testable). */
      getOption(): Record<string, unknown> {
        const colors = getChartColors()
        const xField = this.xField
        const yField = this.yField
        const sizeField = this.sizeField
        const categoryField = this.categoryField
        const fallbackSize = this.symbolSize
        const symbol = this.symbol
    
        const categories = this.categories()
        const series = categories.map((cat, i) => ({
          name: cat,
          type: 'scatter',
          symbol,
          symbolSize: sizeField ? (val: unknown[]) => Math.sqrt(Number(val[2]) || 0) * 3 + 4 : fallbackSize,
          itemStyle: { color: colors[i % colors.length] },
          data: (categoryField ? this.data.filter((d) => d[categoryField] === cat) : this.data).map((d) => [
            d[xField],
            d[yField],
            sizeField ? d[sizeField] : 0,
          ]),
        }))
    
        const userOption: Record<string, any> = this.option ?? {}
        const {
          series: userSeries,
          xAxis: userXAxis,
          yAxis: userYAxis,
          grid: userGrid,
          tooltip: userTooltip,
          legend: userLegend,
          ...userRest
        } = userOption
        const mergedSeries = Array.isArray(userSeries) ? series.map((s, i) => ({ ...s, ...(userSeries[i] ?? {}) })) : series
    
        const baseLegend =
          categories.length > 1
            ? {
                bottom: 0,
                icon: 'circle',
                itemWidth: 8,
                itemHeight: 8,
                textStyle: { fontSize: 11, color: getChartTextColor() },
              }
            : undefined
    
        return {
          color: colors,
          grid: mergeOptionBlock(
            {
              left: 16,
              right: 16,
              top: 24,
              bottom: categories.length > 1 ? 32 : 24,
              outerBoundsMode: 'same',
              outerBoundsContain: 'axisLabel',
            },
            userGrid,
          ),
          tooltip: mergeOptionBlock(
            {
              trigger: 'item',
              backgroundColor: getChartTooltipBg(),
              borderColor: getChartTooltipBorder(),
              textStyle: { color: getChartTooltipText(), fontSize: 12 },
              formatter: (params: any) =>
                `${params.seriesName}<br/>${xField}: ${params.value[0]}<br/>${yField}: ${params.value[1]}`,
            },
            userTooltip,
          ),
          legend: userLegend?.show === false ? undefined : mergeOptionBlock(baseLegend ?? {}, userLegend),
          xAxis: mergeOptionBlock(
            {
              type: 'value',
              splitLine: { lineStyle: { color: getChartSplitLineColor() } },
              axisLabel: { color: getChartTextColor(), fontSize: 11 },
              axisLine: { lineStyle: { color: getChartAxisColor() } },
              axisTick: { show: false },
              scale: true,
            },
            userXAxis,
          ),
          yAxis: mergeOptionBlock(
            {
              type: 'value',
              splitLine: { lineStyle: { color: getChartSplitLineColor() } },
              axisLabel: { color: getChartTextColor(), fontSize: 11 },
              axisLine: { show: false },
              axisTick: { show: false },
              scale: true,
            },
            userYAxis,
          ),
          series: mergedSeries,
          ...userRest,
        }
      }
    
      /** ECharts chart + component modules, resolved lazily so SSR never loads canvas code. */
      private chartModules(charts: Record<string, unknown>, comps: Record<string, unknown>): unknown[] {
        return [charts['ScatterChart'], comps['GridComponent'], comps['TooltipComponent'], comps['LegendComponent']]
      }
    
      async ngAfterViewInit(): Promise<void> {
        if (typeof window === 'undefined' || !this.chartEl?.nativeElement) return
        const [{ use }, { CanvasRenderer }, charts, comps] = await Promise.all([
          import('echarts/core'),
          import('echarts/renderers'),
          import('echarts/charts'),
          import('echarts/components'),
        ])
        use([CanvasRenderer, ...(this.chartModules(charts, comps) as never[])])
        const { init } = await import('echarts/core')
        this.chart = init(this.chartEl.nativeElement)
        this.chart.setOption(this.getOption())
        const { onChartThemeChange } = await import('../use-chart-theme')
        this.unsubscribeTheme = onChartThemeChange(() => this.chart?.setOption(this.getOption()))
        if (typeof ResizeObserver !== 'undefined') {
          new ResizeObserver(() => this.chart?.resize()).observe(this.chartEl.nativeElement)
        }
      }
    
      ngOnChanges(changes: SimpleChanges): void {
        if (this.chart && Object.keys(changes).length) {
          this.chart.setOption(this.getOption())
        }
      }
    
      ngOnDestroy(): void {
        this.unsubscribeTheme?.()
        this.unsubscribeTheme = null
        try {
          this.chart?.dispose()
        } catch {
          /* already disposed */
        }
        this.chart = null
      }
    }
    
  • src/app/components/ui/charts/scatter-chart/index.ts0.1 kB
  • src/app/components/ui/charts/use-chart-theme.ts7.4 kB

Raw manifest:https://uipkge.dev/r/angular/scatter-chart.json