SemiLayerDocs

Charts

The intelligence layer ships the data — typed, drillable, optionally live. @semilayer/charts ships the pixels: a tiny vanilla-TypeScript SVG renderer with zero framework dependencies. @semilayer/react-charts wraps the same engine with a <AnalyzeChart> component and a hook for React apps.

<AnalyzeChart shape="bar" beam={beam.products.analyze.byCategory} />
live · animated · drillable
thaiitalianmexicanjapanese58
npm install @semilayer/charts             # vanilla — works in any framework
npm install @semilayer/react-charts       # React 19 + the typed Beam handle

Pick the shape, hand it the result, and it renders. Click a bucket and the chart calls back with the signed bucketKey that round-trips to drill-down. Subscribe over the WS and bucket nodes animate in place as analyze.diff frames arrive.

React — the easy path

import { AnalyzeChart } from '@semilayer/react-charts'
import { useState } from 'react'
import { useAnalyzeRows } from '@semilayer/react'

export function ProductsByCategory() {
  const [bucketKey, setBucketKey] = useState<string | null>(null)

  return (
    <>
      <AnalyzeChart
        beam={beam.products.analyze.byCategory}
        shape="bar"
        liveUpdates                              // ← WS subscribe + apply diffs in place
        onBucketClick={(key) => setBucketKey(key)}
      />
      {bucketKey && <DrillPanel bucketKey={bucketKey} />}
    </>
  )
}

The component owns the lifecycle: opens the chart, fetches once on mount, subscribes to analyze.subscribe when liveUpdates is set, applies analyze.diff frames in place via CSS transitions, and tears down the socket on unmount. No render churn — bucket nodes update opacity / fill without remounting.

The useAnalyzeChart hook gives you the same wiring with a ref-based API when you need to pin a chart to a div you control:

import { useAnalyzeChart } from '@semilayer/react-charts'

function PriceDistribution() {
  const { ref, exportCSV, exportSVG, refetch, evolved } = useAnalyzeChart(
    beam.products.analyze.priceDistribution,
    { shape: 'line', liveUpdates: true, theme: 'dark' },
  )
  return (
    <>
      <div ref={ref} style={{ height: 300 }} />
      {evolved && <Pill>Top buckets reordered — refresh?</Pill>}
      <button onClick={() => exportCSV()}>Download CSV</button>
    </>
  )
}

Vanilla — createChart

For non-React apps (Vue / Svelte / vanilla / web components), the imperative API mounts a chart into any DOM node:

import { createChart } from '@semilayer/charts'

const chart = createChart(document.getElementById('chart')!, {
  shape: 'donut',
  source: { kind: 'beam', handle: beam.products.analyze.topBrands, liveUpdates: true },
  theme: 'light',
})

chart.onBucketClick((bucketKey) => openDrillPanel(bucketKey))
chart.exportPNG({ scale: 2 }).then((blob) => download('topBrands.png', blob))
chart.destroy()  // idempotent — safe to call multiple times

The same createChart is also available as a custom element so you can drop charts into HTML without any JS framework:

<!-- one-time global registration -->
<script type="module">
  import { registerSemiLayerChartElement } from '@semilayer/charts/element'
  registerSemiLayerChartElement()
</script>

<sl-chart shape="bar" src="/api/cached/byCategory.json"></sl-chart>
<sl-chart shape="treemap" data='{"buckets":[…]}'></sl-chart>

Showcase — every shape

The gallery above shows 4 of the 14 shapes the renderer ships. Same data shape (AnalyzeResult<D, M>); different shape prop. You can swap shapes at runtime without re-fetching the analyze — chart.update({ shape }) or the <AnalyzeChart shape> prop resugars the existing data into the new visual.

ShapeBest for
tableThe fallback view. HTML-in-foreignObject, sortable, copyable.
bar / stackedBarCategorical counts; stacked when you have 2+ measures sharing a baseline.
line / area / stackedAreaOrdered dimensions — time buckets, numeric breaks.
scatter2-measure dim × dim matrix; e.g. price vs rating.
pie / donutShare-of-total, ≤ 6 segments. Donut for "and the total is…" use case.
heatmapTwo-dim grid: cohort matrices, hour-of-day × day-of-week.
funnelThe funnel kind's natural shape — step counts + dropoff bands.
cohortThe cohort kind's natural shape — cohorts × intervals retention matrix.
treemapCategorical share-of-total when you have ≥ 6 buckets and want everything visible.
radarMulti-measure profile — e.g. one product's score across 5 axes.
geoGeohash / H3 cells — vertical-bar fallback per cell.

Every shape consumes the same headless reshape utilities from @semilayer/headlesstoLineSeries, toStackedSeries, toScatterPoints, toHeatmapMatrix, toFunnelSteps, toCohortMatrix, etc. — so the data math is canonicalized in one place. If you want the math without the rendering layer (e.g. driving a third-party library like Recharts or D3), import those utilities directly:

import { toLineSeries } from '@semilayer/headless'

const result = await beam.products.analyze.priceDistribution({})
const series = toLineSeries(result, { x: 'price_band', y: 'count' })
//   ^^^^^^  → ready for any charting library that accepts (x, y) tuples

Theming

Themes are first-class. lightTheme + darkTheme ship out of the box; resolveTheme(partial) shallow-merges any overrides; themeFromCssVars(host, mapping) wires the chart palette to your existing CSS variables for a pixel-perfect match with the rest of the app:

import { themeFromCssVars } from '@semilayer/charts'

const theme = themeFromCssVars(document.documentElement, {
  text:       '--app-text',
  textMuted:  '--app-text-muted',
  background: '--app-bg',
  // palette accepts an array — analyze accent first, brand colors after.
  palette:    ['--accent-orange', '--brand-purple', '--brand-blue', '--brand-green'],
})

createChart(host, { shape: 'bar', source: { kind: 'beam', handle }, theme })

Switch themes at runtime with chart.setTheme(partial) — the renderer re-runs the layout and re-paints in place.

Animations

Bucket nodes animate in / out via CSS transitions on opacity + fill — no canvas churn, no render-thrash. The renderer respects prefers-reduced-motion automatically: effectiveAnimationMs(theme) returns 0 when the OS-level reduced-motion preference is set, so users who disable animations get static updates without any opt-in code on your side.

createChart(host, {
  shape: 'line',
  source: { kind: 'beam', handle, liveUpdates: true },
  animationMs:     300,           // override the default
  animationEasing: 'ease-out',
})

When liveUpdates is on and a analyze.diff frame arrives, only the added / changed / removed buckets transition. Unchanged buckets sit still — the chart never thrashes.

Exports

Every chart can dump its current state in four formats:

chart.exportCSV()   // → string. RFC-4180 with union-key headers.
chart.exportJSON()  // → string. The raw AnalyzeResult.
chart.exportSVG()   // → string. Standalone xmlns markup, ready to ship to disk.
chart.exportPNG({ width, height, scale })  // → Promise<Blob>. 2× retina by default.

The CSV export is union-keyed across sparse buckets — if some buckets have a top_k measure that others don't, the header still shows every column. PNG exports are rendered through Image + canvas; the dimensions default to the chart's current size, multiplied by scale for retina output.

Streaming exports — the row-level dump

Charts dump their aggregated state. To dump the contributing rows behind a chart, reach for the streaming row export instead:

import { useExportRows } from '@semilayer/react'

const exp = useExportRows(beam.products.analyze.byCategory, {
  bucketKey,        // from chart.onBucketClick
  format: 'ndjson',
})
// exp.start() / exp.cancel() / exp.status / exp.progress

See Analyze → Exports for the full streaming contract, tier caps, and the X-SemiLayer-Export-Truncated trailer.

Source adapters — beyond Beam

@semilayer/charts works with four source shapes:

kindWhat it doesRight for
'data'Pass an AnalyzeResult you already haveServer-rendered pages, fixtures, demos
'beam'Hand a typed Beam handle; the chart owns the fetch + WSReact apps with codegen
'http'Hand a URL; the chart polls on pollMsStatic sites, CDN-cached results
'ws'Hand an open socket; chart sends analyze.subscribeApps with their own socket lifecycle
// 'data' — for stories / fixtures
createChart(host, { shape: 'bar', source: { kind: 'data', result } })

// 'http' — poll a cached endpoint
createChart(host, { shape: 'line', source: { kind: 'http', url: '/api/byMonth.json', pollMs: 30_000 } })

// 'ws' — share a socket with the rest of your app
createChart(host, { shape: 'bar', source: { kind: 'ws', socket, op: 'analyze.subscribe', params: { lens, name } } })

Where to start

  • Analyze Overview — what the underlying data shape looks like before it hits a chart.
  • Drill-down — the bucketKey round-trip that every chart's onBucketClick handler hooks into.
  • Exports — pull the rows behind a bucket as streaming NDJSON or CSV.
  • demo.semilayer.com/analyze — the dogfood. Four shapes, click-to-drill, search-inside-bucket, all rendered through the vanilla @semilayer/charts package (no React on this demo).