Histogram

Basic Histogram

Bar styling with border_radius for rounded corners and stroke_color/stroke_width for borders. The marginals prop adds a cumulative-distribution (CDF) strip on top. It accepts histogram, kde, cdf, or rug on any side and works on every 2D plot (see the full reference):

Interactive example

svelte<script lang="ts">
  import { format_num, Histogram, type HistogramHandlerProps } from 'matterviz'
  import { generate_normal } from '#site/histogram-data.js'

  let bins = $state(50)
  let sample_size = $state(1000)
  let show_controls = $state(true)
  let border_radius = $state(2)
  let hover_info = $state('Hover over a bar to see details')
  let click_info = $state('Click on a bar to select it')

  let data = $derived({
    values: generate_normal(sample_size, 50, 15),
    label: `Normal Distribution (N=${format_num(sample_size, `~s`)}, μ=50, σ=15)`,
  })

  function handle_bar_hover(data: HistogramHandlerProps | null): void {
    if (data) {
      const { value, count, property } = data
      hover_info = `Hovering: ${property} - Value: ${value.toFixed(
        1,
      )}, Count: ${count}, Percentage: ${format_num(count / sample_size, `.2~%`)}`
    } else {
      hover_info = 'Hover over a bar to see details'
    }
  }

  function handle_bar_click(data: HistogramHandlerProps): void {
    const { value, count, property } = data
    click_info = `Clicked: ${property} - Value: ${value.toFixed(
      1,
    )}, Count: ${count}, Percentage: ${format_num(count / sample_size, `.2~%`)}`
  }

  const info_style =
    'margin: 1em 0; padding: 2pt 5pt; background-color: rgba(255, 255, 255, 0.1); border-radius: 4px'
</script>

<div style="display: flex; flex-wrap: wrap; gap: 1em; align-items: center; margin-bottom: 1em">
  <label style="display: flex; align-items: center; gap: 4px"
    >Bins: {bins}<input type="range" bind:value={bins} min="5" max="200" /></label
  >
  <label style="display: flex; align-items: center; gap: 4px"
    >Size: {sample_size}
    <input type="range" bind:value={sample_size} min="100" max="10000" step="100" />
  </label>
  <label style="display: flex; align-items: center; gap: 4px"
    ><input type="checkbox" bind:checked={show_controls} />Controls</label
  >
  <label style="display: flex; align-items: center; gap: 4px"
    >Radius: {border_radius}
    <input type="range" bind:value={border_radius} min="0" max="8" />
  </label>
</div>

{#snippet tooltip({ value, count })}
  Value: {value.toFixed(1)}<br />Count: {count}<br />
  %: {format_num(count / sample_size, `.2~%`)}
{/snippet}

<Histogram
  series={[data]}
  {bins}
  {show_controls}
  range_padding={0}
  bar={{ border_radius, stroke_color: `#364fc7`, stroke_width: 0.5 }}
  y_axis={{ label: `Count (N=${format_num(sample_size, `~s`)})` }}
  on_bar_hover={handle_bar_hover}
  on_bar_click={handle_bar_click}
  {tooltip}
  marginals={{ top: `cdf` }}
  style="height: 400px"
/>

<div style={info_style} data-testid="hover-status">{hover_info}</div>
<div style={info_style} data-testid="click-status">{click_info}</div>

Dual Y-Axes for Different Sample Sizes

When sample sizes differ a lot, use dual y-axes for independent scaling. Test scores from two cohorts (1000 vs 200 samples):

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_normal } from '#site/histogram-data.js'

  let display = $state({ x_grid: true, y_grid: false, y2_grid: false })
  let series = $state([
    {
      values: generate_normal(1000, 75, 12),
      label: `Main Cohort (n=1000)`,
      color: `steelblue`,
    },
    {
      values: generate_normal(200, 82, 10),
      label: `Control Group (n=200)`,
      color: `coral`,
      y_axis: `y2`,
    },
  ])
</script>

<div style="display: flex; gap: 1em; align-items: center; margin-bottom: 1em">
  <label style="display: flex; align-items: center; gap: 4px"
    ><input type="checkbox" bind:checked={display.x_grid} />X grid</label
  >
  <label style="display: flex; align-items: center; gap: 4px"
    ><input type="checkbox" bind:checked={display.y_grid} />Y1 grid</label
  >
  <label style="display: flex; align-items: center; gap: 4px"
    ><input type="checkbox" bind:checked={display.y2_grid} />Y2 grid</label
  >
</div>

<Histogram
  {series}
  mode="overlay"
  bins={40}
  x_axis={{ label: `Test Score` }}
  y_axis={{ label: `Count (Main Cohort)` }}
  y2_axis={{ label: `Count (Control)` }}
  bind:display
  bar={{ opacity: 0.6 }}
  style="height: 400px"
>
  {#snippet tooltip({ value, count, property })}
    <strong>{property}</strong><br />
    Score: {value.toFixed(1)}<br />Count: {count}
  {/snippet}
</Histogram>

Multiple Histograms with Dual Y-Axes

Compare distributions with vastly different scales using dual y-axes. Some distributions use the left axis, while others use the independent right y2-axis:

Use mode="single" with bind:selected_series_idx to show one series at a time. The index refers to the original series array, so duplicate or missing labels work independently. If that series is hidden or removed, the chart displays the first visible series. The single-distribution toggle in Reference Lines shows this mode.

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '#site/histogram-data.js'

  let x_axis = $state({ scale_type: `linear` })
  let y_axis = $state({ scale_type: `linear`, label: `Count (Normal/Uniform)` })
  let y2_axis = $state({ scale_type: `linear`, label: `Count (Exp/Gamma)` })
  let display = $state({ x_grid: true, y_grid: true, y2_grid: false })
  let bar = $state({ opacity: 0.6, stroke_width: 1.5 })

  const base_series = [
    {
      values: utils.generate_normal(1200, 5, 2),
      label: `Normal (μ=5, σ=2)`,
      color: `crimson`,
    },
    {
      values: utils.generate_exponential(1200, 0.3),
      label: `Exponential (λ=0.3)`,
      color: `royalblue`,
      y_axis: `y2`,
    },
    {
      values: utils.generate_uniform(1200, 0, 15),
      label: `Uniform (0-15)`,
      color: `mediumseagreen`,
    },
    {
      values: utils.generate_gamma(1000, 2, 3),
      label: `Gamma (α=2, β=3)`,
      color: `darkorange`,
      y_axis: `y2`,
    },
  ]
  let visible = $state(base_series.map(() => true))
  let series = $derived(base_series.map((srs, idx) => ({ ...srs, visible: visible[idx] })))
</script>

<div style="display: flex; gap: 1em; flex-wrap: wrap; margin-block: 2em; align-items: center;">
  <label
    >Opacity:
    <input type="number" bind:value={bar.opacity} min="0.1" max="1" step="0.1" />
    <input type="range" bind:value={bar.opacity} min="0.1" max="1" step="0.1" />
  </label>
  <label
    >Stroke Width:
    <input type="number" bind:value={bar.stroke_width} min="0" max="5" step="0.5" />
    <input type="range" bind:value={bar.stroke_width} min="0" max="5" step="0.5" />
  </label>

  <label style="display: flex; gap: 5pt"
    >X: {#each [`linear`, `log`] as scale (scale)}
      <input type="radio" bind:group={x_axis.scale_type} value={scale} />{scale}
    {/each}</label
  >
  <label style="display: flex; gap: 5pt"
    >Y1: {#each [`linear`, `log`] as scale (scale)}
      <input type="radio" bind:group={y_axis.scale_type} value={scale} />{scale}
    {/each}</label
  >
  <label style="display: flex; gap: 5pt"
    >Y2: {#each [`linear`, `log`] as scale (scale)}
      <input type="radio" bind:group={y2_axis.scale_type} value={scale} />{scale}
    {/each}</label
  >

  <label style="display: flex; align-items: center; gap: 4px"
    ><input type="checkbox" bind:checked={display.x_grid} />X grid</label
  >
  <label style="display: flex; align-items: center; gap: 4px"
    ><input type="checkbox" bind:checked={display.y_grid} />Y1 grid</label
  >
  <label style="display: flex; align-items: center; gap: 4px"
    ><input type="checkbox" bind:checked={display.y2_grid} />Y2 grid</label
  >
</div>

{#each base_series as srs, idx (srs.label)}
  <label style="display: flex; align-items: center; gap: 4px">
    <input type="checkbox" bind:checked={visible[idx]} />
    <span style="width: 16px; height: 16px; background: {srs.color}"></span>
    {srs.label}
    {srs.y_axis === `y2` ? `(Y2)` : `(Y1)`}
  </label>
{/each}

<Histogram
  {series}
  mode="overlay"
  bins={50}
  bind:bar
  bind:x_axis
  bind:y_axis
  bind:y2_axis
  bind:display
  style="height: 450px; margin-block: 1em;"
>
  {#snippet tooltip({ value, count, property })}
    <strong style="color: {series.find((srs) => srs.label === property)?.color}"
      >{property}</strong
    ><br />
    Value: {value.toFixed(2)}<br />Count: {count}
  {/snippet}
</Histogram>

Logarithmic Scales

Bins are uniform in the x axis’s own space: bins equal-width bins on a linear axis, bins equal-ratio bins (uniform in log10 x) on a log axis, and uniform in asinh(x / threshold) on an arcsinh axis. Switching the x scale below re-bins the same samples so every bar stays the same width on screen.

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '#site/histogram-data.js'

  let x_axis = $state({ scale_type: `linear` })
  let y_axis = $state({ scale_type: `log` })
  $effect(() => {
    x_axis.label = `Value (${x_axis.scale_type} scale)`
    x_axis.format = x_axis.scale_type === `log` ? `~s` : `d`
    y_axis.label = `Frequency (${y_axis.scale_type} scale)`
    y_axis.format = y_axis.scale_type === `log` ? `~s` : `d`
  })
  let bins = $state(40)

  let series = $state([
    {
      values: utils.generate_log_normal(1500, 2, 1),
      label: `Log-Normal (μ=2, σ=1)`,
      color: `darkorange`,
    },
    {
      values: utils.generate_power_law(1500, 2.5),
      label: `Power Law (α=2.5)`,
      color: `darkgreen`,
    },
    {
      values: utils.generate_pareto(1200, 1, 3),
      label: `Pareto (α=3)`,
      color: `darkviolet`,
    },
  ])
</script>

<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 1.5em">
  <fieldset>
    <legend>X</legend>
    {#each [`linear`, `log`] as scale (scale)}
      <label><input type="radio" bind:group={x_axis.scale_type} value={scale} />{scale}</label>
    {/each}
  </fieldset>
  <fieldset>
    <legend>Y</legend>
    {#each [`linear`, `log`] as scale (scale)}
      <label><input type="radio" bind:group={y_axis.scale_type} value={scale} />{scale}</label>
    {/each}
  </fieldset>
  <label style="display: flex; align-items: center; gap: 0.5em">
    Bins: {bins}<input type="range" bind:value={bins} min="10" max="100" step="5" />
  </label>
</div>

<Histogram
  {series}
  mode="overlay"
  {bins}
  bind:x_axis
  bind:y_axis
  style="height: 450px; margin-block: 1em"
>
  {#snippet tooltip({ value, count, property })}
    <strong>{property}</strong><br />
    Value: {value.toExponential(2)}<br />Count: {count}
  {/snippet}
</Histogram>

Arcsinh Scale: Handling Data with Negative Values

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_signed_data, seeded_rng } from '#site/histogram-data.js'

  const scale_types = [`linear`, `log`, `arcsinh`]
  let x_scale_type = $state(`arcsinh`)
  let y_scale_type = $state(`linear`)
  let arcsinh_threshold = $state(10)

  // Positive, negative and near-zero values from a fixed seed so the plot is stable on reload
  const mixed_data = generate_signed_data(2000, seeded_rng(42))

  let x_axis = $derived({
    label: `Value (${x_scale_type})`,
    scale_type:
      x_scale_type === `arcsinh`
        ? { type: `arcsinh`, threshold: arcsinh_threshold }
        : x_scale_type,
  })

  let y_axis = $derived({
    label: `Count (${y_scale_type})`,
    scale_type: y_scale_type,
  })
</script>

<div
  style="display: flex; flex-wrap: wrap; align-items: center; gap: 1em; margin-bottom: 1em; font-size: 0.9em"
>
  <fieldset>
    <legend>X-axis Scale</legend>
    {#each scale_types as scale (scale)}
      <label>
        <input type="radio" bind:group={x_scale_type} value={scale} />
        {scale}
      </label>
    {/each}
  </fieldset>
  <fieldset>
    <legend>Y-axis Scale</legend>
    {#each scale_types as scale (scale)}
      <label>
        <input type="radio" bind:group={y_scale_type} value={scale} />
        {scale}
      </label>
    {/each}
  </fieldset>
  {#if x_scale_type === `arcsinh` || y_scale_type === `arcsinh`}
    <label style="display: flex; align-items: center; gap: 0.35em">
      Arcsinh Threshold: {arcsinh_threshold}
      <input
        type="range"
        bind:value={arcsinh_threshold}
        min="0.1"
        max="100"
        step="0.1"
        style="width: 100px"
      />
    </label>
  {/if}
</div>

<Histogram
  series={[
    {
      values: mixed_data,
      label: `Mixed Range Data`,
      color: `#4c6ef5`,
    },
  ]}
  bins={60}
  bind:x_axis
  bind:y_axis
  style="height: 400px"
>
  {#snippet tooltip({ value, count })}
    Value: {value.toFixed(1)}<br />Count: {count}
  {/snippet}
</Histogram>

Real-World Distributions

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '#site/histogram-data.js'
  import { format_num } from 'matterviz'

  let selected = $state(`bimodal`)
  let mode = $state(`single`)
  let x_axis = $state({})
  let y_axis = $state({ label: `Count` })
  $effect(() => {
    x_axis.label = { discrete: `Rating`, age: `Age` }[selected] ?? `Value`
    x_axis.format = selected === `discrete` ? `.1f` : `.0f`
  })

  let distributions = $derived({
    bimodal: {
      data: utils.generate_bimodal(1500),
      label: `Bimodal Distribution`,
      color: `#e74c3c`,
    },
    skewed: {
      data: utils.generate_skewed(1200),
      label: `Right-Skewed Distribution`,
      color: `#3498db`,
    },
    discrete: {
      data: utils.generate_discrete(1000),
      label: `Survey Responses (1-10)`,
      color: `#2ecc71`,
    },
    age: {
      data: utils.generate_age_distribution(2000),
      label: `Age Distribution`,
      color: `#9b59b6`,
    },
    mixture: {
      data: utils.generate_mixture(1800),
      label: `Complex Mixture`,
      color: `#f39c12`,
    },
  })

  let current = $derived(distributions[selected])
  let series_data = $derived(
    mode === `single`
      ? [
          {
            values: current.data,
            label: current.label,
            color: current.color,
          },
        ]
      : Object.entries(distributions).map(([key, dist]) => ({
          values: dist.data,
          label: dist.label,
          color: dist.color,
          visible: key === selected,
        })),
  )
</script>

<select bind:value={selected}>
  {#each Object.entries(distributions) as [key, dist] (key)}
    <option value={key}>{dist.label}</option>
  {/each}
</select>

<div style="display: flex; gap: 1em; align-items: center; margin-bottom: 1em">
  {#each [`single`, `overlay`] as display_mode (display_mode)}
    <label style="display: flex; align-items: center; gap: 4px"
      ><input type="radio" bind:group={mode} value={display_mode} />{display_mode}</label
    >
  {/each}
</div>

<Histogram
  series={series_data}
  {mode}
  bind:x_axis
  bind:y_axis
  bins={selected === `discrete` ? 10 : 40}
  show_legend={mode === `overlay`}
  style="height: 450px; margin-block: 1em"
>
  {#snippet tooltip({ value, count, property })}
    <strong>{property}</strong><br />
    {{ age: `Age`, discrete: `Rating` }[selected] ?? `Value`}: {format_num(
      value,
      selected === `discrete` ? `.1f` : `.0f`,
    )}<br />
    Count: {count}<br />%: {format_num(count / current.data.length, `.2~%`)}
  {/snippet}
</Histogram>

Normalization: Counts, Probabilities and Densities

normalize scales the bar heights: count (default) shows raw counts, probability the fraction of in-range samples per bin (bars sum to 1), and density a probability density (count / (N · bin width), so the bars integrate to 1 even on log-spaced bins). Two samples of different size and bin count become comparable under density:

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_normal } from '#site/histogram-data.js'

  let normalize = $state(`density`)
  let bins = $state(40)
  const series = [
    {
      values: generate_normal(5000, 0, 1),
      label: `N(0, 1), n=5000`,
      color: `steelblue`,
    },
    {
      values: generate_normal(500, 1, 2),
      label: `N(1, 2), n=500`,
      color: `darkorange`,
    },
  ]
</script>

<div style="display: flex; gap: 1.5em; align-items: center; flex-wrap: wrap">
  {#each [`count`, `probability`, `density`] as option (option)}
    <label><input type="radio" bind:group={normalize} value={option} /> {option}</label>
  {/each}
  <label
    >Bins: {bins}<input type="range" bind:value={bins} min="10" max="100" step="5" /></label
  >
</div>

<Histogram
  {series}
  bind:normalize
  bind:bins
  mode="overlay"
  show_legend
  style="height: 400px"
/>

Bin Size Comparison

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '#site/histogram-data.js'

  let bin_counts = $state([10, 25, 50, 100])
  let data_type = $state(`mixed`)
  let bar = $state({ opacity: 0.8 })

  const base_data = $derived(
    data_type === `mixed`
      ? utils.generate_mixed_data(3000)
      : utils.generate_complex_distribution(3000),
  )
  const colors = [`#e74c3c`, `#3498db`, `#2ecc71`, `#f39c12`]
</script>

<div style="display: flex; flex-wrap: wrap; gap: 1em; align-items: center; margin-bottom: 1em">
  {#each [`mixed`, `complex`] as type (type)}
    <label style="display: flex; align-items: center; gap: 4px">
      <input type="radio" bind:group={data_type} value={type} />{type}
    </label>
  {/each}
  {#each bin_counts as count, idx (idx)}
    <label style="display: flex; align-items: center; gap: 4px; color: {colors[idx]}">
      {count} bins:
      <input type="range" bind:value={bin_counts[idx]} min="5" max="200" step="5" />
    </label>
  {/each}
</div>

<div
  style="display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 0"
>
  {#each bin_counts as bins, idx (idx)}
    <Histogram
      series={[{ values: base_data, label: `${bins} bins`, color: colors[idx] }]}
      {bins}
      bar={{ ...bar, color: colors[idx] }}
      x_axis={{ label: `Value` }}
      y_axis={{ label: `Count` }}
      style="height: 280px"
    />
  {/each}
</div>

Custom Styling

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '#site/histogram-data.js'

  let color_scheme = $state(`default`)
  let x_format = $state(`number`)
  let y_format = $state(`count`)
  let data_source = $state(`financial`)

  const color_schemes = {
    default: [`#3498db`],
    warm: [`#e74c3c`, `#f39c12`, `#e67e22`],
    cool: [`#3498db`, `#2ecc71`, `#1abc9c`],
    monochrome: [`#2c3e50`, `#34495e`, `#7f8c8d`],
  }

  const x_formats = {
    number: `.1f`,
    scientific: `.2e`,
    percentage: `.1%`,
    currency: `$,.0f`,
    engineering: `.2~s`,
  }
  const y_formats = { count: `d`, percentage: `.1%`, thousands: `,.0f`, scientific: `.1e` }
  // percentages need per-bin fractions, not counts formatted as percent
  let normalize = $derived(y_format === `percentage` ? `probability` : `count`)

  let x_axis = $state({})
  let y_axis = $state({})
  $effect(() => {
    x_axis.label = x_format === `currency` ? `Stock Price` : `Value`
    x_axis.format = x_formats[x_format]
    y_axis.label = y_format === `percentage` ? `Percentage` : `Count`
    y_axis.format = y_format === `percentage` ? `.1%` : y_formats[y_format]
  })
  let data = $derived(
    data_source === `financial`
      ? utils.generate_financial_data(1200)
      : utils.generate_scientific_data(1200),
  )
  let series = $derived([
    {
      values: data,
      label: data_source === `financial` ? `Stock Prices` : `Scientific Measurements`,
      color: color_schemes[color_scheme][0],
    },
  ])
</script>

<div style="display: flex; gap: 1em; align-items: center; margin-bottom: 1em">
  {#each [`financial`, `scientific`] as source (source)}
    <label style="display: flex; align-items: center; gap: 4px"
      ><input type="radio" bind:group={data_source} value={source} />{source}</label
    >
  {/each}
</div>

<select bind:value={color_scheme}>
  {#each Object.keys(color_schemes) as scheme (scheme)}<option value={scheme}>{scheme}</option
    >{/each}
</select>

<select bind:value={x_format}>
  {#each Object.entries(x_formats) as [key, format] (key)}<option value={key}
      >{key} ({format})</option
    >{/each}
</select>

<select bind:value={y_format}>
  {#each Object.entries(y_formats) as [key, format] (key)}<option value={key}
      >{key} ({format})</option
    >{/each}
</select>

<Histogram
  {series}
  bind:x_axis
  bind:y_axis
  {normalize}
  bins={35}
  style="height: 450px; border: 2px solid {color_schemes[
    color_scheme
  ][0]}; border-radius: 8px;"
>
  {#snippet tooltip({ value, count, property })}
    <div
      style="background: {color_schemes[
        color_scheme
      ][0]}; color: white; padding: 8px; border-radius: 6px;"
    >
      <strong>{property}</strong><br />
      {x_format === `currency`
        ? `Price: $${value.toFixed(0)}`
        : `Value: ${value.toFixed(2)}`}<br />
      Count: {count}
    </div>
  {/snippet}
</Histogram>

Performance Test

Binning is a single pass over the samples (100k values into 200 bins takes ~3 ms), so the render cost is set by how the data is held. Keep large sample arrays out of deep $state: a $state([...]) wraps every element in a reactive proxy, and each read then goes through a trap and registers a dependency, which makes the same 100k-sample update ~10x slower (130 ms vs 13 ms). Use $state.raw, $derived (as below) or a plain const, and replace the array to update.

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '#site/histogram-data.js'

  let dataset_size = $state(10000)
  let data_type = $state(`normal`)
  let bins = $state(50)
  let mode = $state(`single`)

  let performance_data = $derived({
    normal: utils.generate_large_dataset(dataset_size, `normal`),
    uniform: utils.generate_large_dataset(dataset_size, `uniform`),
    sparse: utils.generate_sparse_data(dataset_size),
  })

  let series_data = $derived(
    mode === `single`
      ? [
          {
            values: performance_data[data_type],
            label: `${data_type} (${dataset_size.toLocaleString()} points)`,
            color: `#2c3e50`,
          },
        ]
      : Object.entries(performance_data).map(([key, data]) => ({
          values: data,
          label: `${key} (${data.length.toLocaleString()} points)`,
          color: key === `normal` ? `#e74c3c` : key === `uniform` ? `#3498db` : `#2ecc71`,
          visible: key === data_type,
        })),
  )
</script>

<label
  >Size: {dataset_size.toLocaleString()}<input
    type="range"
    bind:value={dataset_size}
    min="1000"
    max="50000"
    step="1000"
  /></label
>

{#each [`normal`, `uniform`, `sparse`] as type (type)}<label
    ><input type="radio" bind:group={data_type} value={type} />{type}</label
  >{/each}

<label>Bins: {bins}<input type="range" bind:value={bins} min="10" max="200" step="10" /></label
>

{#each [`single`, `overlay`] as display_mode (display_mode)}
  <label><input type="radio" bind:group={mode} value={display_mode} />{display_mode}</label>
{/each}

<strong>Performance:</strong>
{data_type} distribution, {dataset_size.toLocaleString()}
points, {bins} bins, {mode} mode

<Histogram
  series={series_data}
  {mode}
  {bins}
  show_legend={mode === `overlay`}
  style="height: 450px; margin-block: 1em"
>
  {#snippet tooltip({ value, count, property })}
    <strong>{property}</strong><br />Value: {value.toFixed(2)}<br />Count: {count}
  {/snippet}
</Histogram>

Reference Lines: Statistical Markers and Distribution Comparison

Use ref_lines to show statistical reference values like mean, median, standard deviations, or to compare distributions against expected values. Toggle between single distribution (with full statistics) and comparison mode:

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_normal } from '#site/histogram-data.js'

  let comparison_mode = $state(false)

  // Single distribution data
  const sample_size = 1000
  const std_dev = 12
  const data = generate_normal(sample_size, 50, std_dev)
  const sorted = [...data].sort((left_value, right_value) => left_value - right_value)
  const actual_mean = data.reduce((sum, val) => sum + val, 0) / data.length
  const actual_median = sorted[Math.floor(sorted.length / 2)]

  // Comparison data
  const sample_a = generate_normal(800, 45, 10)
  const sample_b = generate_normal(800, 55, 8)
  const mean_a = sample_a.reduce((sum, val) => sum + val, 0) / sample_a.length
  const mean_b = sample_b.reduce((sum, val) => sum + val, 0) / sample_b.length

  let series = $derived(
    comparison_mode
      ? [
          { values: sample_a, label: `Control`, color: `#3498db` },
          { values: sample_b, label: `Treatment`, color: `#e74c3c` },
        ]
      : [
          {
            values: data,
            label: `Normal Distribution`,
            color: `#4c6ef5`,
          },
        ],
  )

  let ref_lines = $derived(
    comparison_mode
      ? [
          {
            type: `vertical`,
            x: mean_a,
            label: `Control Mean`,
            style: { color: `#3498db`, width: 2.5 },
            annotation: {
              text: `μ₁ = ${mean_a.toFixed(1)}`,
              position: `end`,
              side: `left`,
            },
          },
          {
            type: `vertical`,
            x: mean_b,
            label: `Treatment Mean`,
            style: { color: `#e74c3c`, width: 2.5 },
            annotation: {
              text: `μ₂ = ${mean_b.toFixed(1)}`,
              position: `end`,
              side: `right`,
            },
          },
          {
            type: `vertical`,
            x: 50,
            label: `Expected`,
            style: { color: `#2ecc71`, width: 2, dash: `8 4` },
            annotation: { text: `Expected = 50`, position: `center`, side: `right` },
            z_index: `below-grid`,
          },
        ]
      : [
          {
            type: `vertical`,
            x: actual_mean,
            label: `Mean`,
            style: { color: `#e74c3c`, width: 2.5 },
            annotation: {
              text: `μ = ${actual_mean.toFixed(1)}`,
              position: `end`,
              side: `right`,
            },
          },
          {
            type: `vertical`,
            x: actual_median,
            label: `Median`,
            style: { color: `#2ecc71`, width: 2, dash: `6 3` },
            annotation: {
              text: `Med = ${actual_median.toFixed(1)}`,
              position: `end`,
              side: `left`,
            },
          },
          {
            type: `vertical`,
            x: actual_mean - std_dev,
            label: `-1σ`,
            style: { color: `#9b59b6`, width: 1.5, dash: `4 2` },
            annotation: { text: `-1σ`, position: `center`, side: `left` },
          },
          {
            type: `vertical`,
            x: actual_mean + std_dev,
            label: `+1σ`,
            style: { color: `#9b59b6`, width: 1.5, dash: `4 2` },
            annotation: { text: `+1σ`, position: `center`, side: `right` },
          },
        ],
  )
</script>

<label style="margin-bottom: 1em; display: block">
  <input type="checkbox" bind:checked={comparison_mode} /> Compare distributions
</label>

<Histogram
  {series}
  {ref_lines}
  mode={comparison_mode ? `overlay` : `single`}
  bins={comparison_mode ? 35 : 40}
  bar={{ opacity: comparison_mode ? 0.5 : 1 }}
  x_axis={{ label: comparison_mode ? `Score` : `Value` }}
  y_axis={{ label: comparison_mode ? `Frequency` : `Count` }}
  style="height: 400px"
/>

<div style="margin-top: 0.5em; font-size: 0.9em; text-align: center">
  {#if comparison_mode}
    Δμ = {(mean_b - mean_a).toFixed(2)} (Treatment − Control)
  {:else}
    <span><strong style="color: #e74c3c">━</strong> Mean: {actual_mean.toFixed(2)}</span>
    <span style="margin-left: 2em"
      ><strong style="color: #2ecc71">╌</strong> Median: {actual_median.toFixed(2)}</span
    >
    <span style="margin-left: 2em"><strong style="color: #9b59b6">┄</strong> ±1σ</span>
  {/if}
</div>

Interactive Axis Labels for Property Exploration

Interactive example

svelte<script lang="ts">
  import { onDestroy } from 'svelte'
  import { type HistogramSeries, Histogram, create_axis_loader, type AxisKey } from 'matterviz'
  import { box_muller, seeded_rng } from '#site/histogram-data.js'

  type DistType =
    | `normal`
    | `exponential`
    | `bimodal`
    | `uniform`
    | `log-normal`
    | `heavy-tail`
    | `skewed`
    | `multimodal`

  // Generate various distributions
  function generate_distribution(type: DistType, count: number, seed: number): number[] {
    const rng = seeded_rng(seed)
    const normal = () => box_muller(0, 1, rng)
    const data: number[] = []

    for (let idx = 0; idx < count; idx++) {
      let val: number
      if (type === `normal`) {
        val = normal() * 1.5 - 2
      } else if (type === `exponential`) {
        val = -Math.log(rng()) * 2
      } else if (type === `bimodal`) {
        val = rng() < 0.4 ? normal() * 0.8 - 3 : normal() * 1.2 + 2
      } else if (type === `uniform`) {
        val = rng() * 10 - 2
      } else if (type === `log-normal`) {
        val = Math.exp(normal() * 0.8)
      } else if (type === `heavy-tail`) {
        val = normal() / (rng() + 0.1)
      } else if (type === `skewed`) {
        const param_u = rng()
        val = Math.pow(param_u, 3) * 15 - 2
      } else {
        // multimodal
        const mode = Math.floor(rng() * 4)
        val = normal() * 0.5 + mode * 3 - 4
      }
      data.push(val)
    }
    return data
  }

  const n_points = 3000 // Per series

  // Pre-generate all distributions for 3 material classes
  const material_classes = [`Oxides`, `Sulfides`, `Nitrides`]
  const colors = [`#e74c3c`, `#3498db`, `#2ecc71`]

  const property_configs = {
    formation_energy: {
      type: `normal`,
      label: `Formation Energy`,
      unit: `eV/atom`,
      bins: 50,
    },
    band_gap: {
      type: `exponential`,
      label: `Band Gap`,
      unit: `eV`,
      bins: 40,
    },
    volume: {
      type: `bimodal`,
      label: `Volume`,
      unit: `ų/atom`,
      bins: 45,
    },
    density: {
      type: `log-normal`,
      label: `Density`,
      unit: `g/cm³`,
      bins: 50,
    },
    bulk_modulus: {
      type: `heavy-tail`,
      label: `Bulk Modulus`,
      unit: `GPa`,
      bins: 60,
    },
    thermal_cond: {
      type: `skewed`,
      label: `Thermal Conductivity`,
      unit: `W/m·K`,
      bins: 45,
    },
    melting_point: {
      type: `uniform`,
      label: `Melting Point`,
      unit: `K`,
      bins: 40,
    },
    hardness: {
      type: `multimodal`,
      label: `Hardness`,
      unit: `GPa`,
      bins: 55,
    },
  }

  // Generate data for all combinations
  const all_data = {}
  let seed = 100
  for (const [prop_key, config] of Object.entries(property_configs)) {
    all_data[prop_key] = material_classes.map((_, idx) => {
      seed += 17
      // Shift each class slightly for variety
      return generate_distribution(config.type, n_points, seed + idx * 1000).map(
        (val) => val + idx * 0.5,
      )
    })
  }

  type PropKey = keyof typeof property_configs

  // Build series for a property
  function build_series(prop_key: PropKey): HistogramSeries[] {
    return material_classes.map((name, idx) => ({
      values: all_data[prop_key][idx],
      label: name,
      color: colors[idx],
      bar_style: { fill: colors[idx], opacity: 0.4 },
    }))
  }

  // State
  let current_prop = $state<PropKey>(`formation_energy`)
  let series = $derived(build_series(current_prop))
  let bins = $state(property_configs.formation_energy.bins)
  let load_times = $state<number[]>([])
  let switch_count = $state(0)
  let load_start = $state(0)

  async function data_loader(_axis: AxisKey, property_key: string): Promise<PropKey> {
    if (!Object.hasOwn(property_configs, property_key))
      throw new Error(`Unknown property: ${property_key}`)
    const key = property_key as PropKey
    load_start = performance.now()
    await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 400))
    return key
  }

  const axis_loader = create_axis_loader(data_loader)
  onDestroy(axis_loader.cancel)

  async function on_axis_change(_axis: AxisKey, property_key: string): Promise<void> {
    const key = await axis_loader.load(_axis, property_key)
    if (key === undefined) return
    switch_count++
    current_prop = key
    bins = property_configs[key].bins
    load_times = [...load_times.slice(-9), Math.round(performance.now() - load_start)]
  }

  // X-axis options
  const x_options = Object.entries(property_configs).map(([key, config]) => ({
    key,
    label: config.label,
    unit: config.unit,
  }))

  let avg_load = $derived(
    load_times.length > 0
      ? Math.round(load_times.reduce((sum, val) => sum + val, 0) / load_times.length)
      : 0,
  )

  let total_points = $derived(n_points * material_classes.length)
</script>

<div style="margin-bottom: 0.5em; font-size: 0.85em; opacity: 0.7">
  Points: <strong>{total_points.toLocaleString()}</strong> | Bins: <strong>{bins}</strong>
  | Switches: <strong>{switch_count}</strong> | Avg load: <strong>{avg_load}ms</strong>
</div>

<Histogram
  {series}
  {bins}
  x_axis={{
    label: `${property_configs[current_prop].label} (${property_configs[current_prop].unit})`,
    options: x_options,
    selected_key: current_prop,
  }}
  y_axis={{ label: `Count` }}
  {on_axis_change}
  bar={{ border_radius: 1 }}
  legend={{ layout: `horizontal`, style: `justify-content: center` }}
  style="height: 400px"
/>

Multiple Plots in 2×2 Grid Layout

Display multiple histograms in a responsive 2×2 grid:

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '#site/histogram-data.js'

  const plots = [
    {
      title: `Normal Distribution`,
      data: utils.generate_normal(1000, 50, 10),
      color: `#4c6ef5`,
      x_label: `Value`,
      bins: 40,
    },
    {
      title: `Exponential Distribution`,
      data: utils.generate_exponential(1000, 0.05),
      color: `#ff6b6b`,
      x_label: `Time`,
      bins: 35,
    },
    {
      title: `Uniform Distribution`,
      data: utils.generate_uniform(1000, 0, 100),
      color: `#51cf66`,
      x_label: `Random Value`,
      bins: 30,
    },
    {
      title: `Gamma Distribution`,
      data: utils.generate_gamma(1000, 2, 15),
      color: `#ffd43b`,
      x_label: `Measurement`,
      bins: 40,
    },
  ]
</script>

<div class="grid">
  {#each plots as { title, data, color, x_label, bins } (title)}
    <div class="cell">
      <h4>{title}</h4>
      <Histogram
        series={[{ values: data, color: color }]}
        {bins}
        x_axis={{ label: x_label }}
        y_axis={{ label: `Count` }}
        show_legend={false}
      />
    </div>
  {/each}
</div>

Y2 Axis Synchronization

Compare distributions on different scales with dual y-axes. Use y2_axis.sync to control axis behavior during zoom/pan.

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_exponential, generate_normal } from '#site/histogram-data.js'

  const n_samples = 200
  const y1_values = generate_normal(n_samples, 50, 15)
  // Exponential tail offset to 1000 so the two series need separate axes
  const y2_values = generate_exponential(n_samples, 1 / 500).map((value) => 1000 + value)

  const series = [
    {
      values: y1_values,
      label: `Sample A`,
      color: `#e74c3c`,
      y_axis: `y`,
    },
    {
      values: y2_values,
      label: `Sample B`,
      color: `#3498db`,
      y_axis: `y2`,
    },
  ]

  const sync_labels = {
    none: `Independent`,
    synced: `Synced`,
    align: `Align`,
  }
  let sync_mode = $state(`synced`)
</script>

<div style="margin-bottom: 1em; display: flex; gap: 1.5em; align-items: center">
  <strong>Y2 Sync:</strong>
  {#each Object.entries(sync_labels) as [mode, label] (mode)}
    <label><input type="radio" bind:group={sync_mode} value={mode} /> {label}</label>
  {/each}
</div>

<Histogram
  {series}
  mode="overlay"
  bins={30}
  x_axis={{ label: `Value` }}
  y_axis={{ label: `Count (A)`, color: `#e74c3c` }}
  y2_axis={{
    label: `Count (B)`,
    color: `#3498db`,
    sync: sync_mode,
  }}
  bar={{ opacity: 0.6 }}
  style="height: 400px"
/>

Dual X-Axes (X2)

Plot two distributions with independent x-scales on the same histogram. The primary x-axis (bottom) shows one unit while the secondary x2-axis (top) shows another. This is useful when comparing the same physical quantity measured in different units (e.g. mass in kilograms vs pounds).

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_normal, seeded_rng } from '#site/histogram-data.js'

  // Seeded so the two mass distributions are reproducible on reload
  const rng = seeded_rng(42)
  const kg_values = generate_normal(400, 70, 10, rng)
  const lbs_values = generate_normal(400, 154, 22, rng)

  const series = [
    {
      x: kg_values.map((_, idx) => idx),
      values: kg_values,
      label: `Mass (kg)`,
      color: `#0ea5e9`,
      point_style: { fill: `#0ea5e9` },
    },
    {
      x: lbs_values.map((_, idx) => idx),
      values: lbs_values,
      label: `Mass (lbs)`,
      x_axis: `x2`,
      color: `#f97316`,
      point_style: { fill: `#f97316` },
    },
  ]
</script>

Two normal distributions on independent x-scales. Bottom: mass in kg (blue). Top: mass in lbs
(orange). Each series bins against its own x-axis range.

<Histogram
  {series}
  bins={25}
  mode="overlay"
  show_legend
  x_axis={{ label: `Mass (kg)`, color: `#0ea5e9` }}
  x2_axis={{ label: `Mass (lbs)`, color: `#f97316` }}
  y_axis={{ label: `Count` }}
  style="height: 400px"
/>

Responsive Title, Subtitle, and Axis Titles

Plot titles, subtitles, and axis-title blocks use measured font metrics. Resize this container to see long text wrap and the plot padding update without clipping the data area.

Interactive example

svelte<script lang="ts">
  import { Histogram } from 'matterviz'

  const volume_samples = Array.from(
    { length: 700 },
    (_, sample_idx) =>
      14 +
      2.7 * Math.sin(sample_idx * 1.618) +
      1.3 * Math.cos(sample_idx * 0.271) +
      (sample_idx % 11) / 10,
  )
  let plot_width = $state(420)
  let title_align = $state<`start` | `middle` | `end`>(`start`)
</script>

<div style="display: flex; flex-wrap: wrap; gap: 1em 2em; align-items: center">
  <label
    >Width: {plot_width}px
    <input type="range" bind:value={plot_width} min="300" max="900" step="20" /></label
  >
  <label
    >Title alignment: <select bind:value={title_align}
      ><option value="start">start</option><option value="middle">middle</option><option
        value="end">end</option
      ></select
    ></label
  >
</div>

<div style={`width: min(100%, ${plot_width}px); margin: 1em auto`}>
  <Histogram
    series={[{ values: volume_samples, label: `Relaxed structures`, color: `#7950f2` }]}
    bins={36}
    title={{
      text: `Distribution of symmetry-standardized atomic volumes after structural relaxation`,
      subtitle: `700 deterministic samples; drag the width control to exercise live text measurement`,
      align: title_align,
      max_lines: 3,
    }}
    x_axis={{
      label: `Atomic volume after symmetry-standardized structural relaxation (A^3/atom)`,
    }}
    y_axis={{
      label: `Number of structures retained after all validation and quality-control filters`,
    }}
    show_controls={false}
    style="height: 480px"
  />
</div>