Histogram

Basic Histogram

This example demonstrates bar styling options including border_radius for rounded corners and stroke_color/stroke_width for bar borders:

Value
Count
Hover over a bar to see details
Click on a bar to select it
<script lang="ts">
  import { format_num, Histogram, type HistogramHandlerProps } from 'matterviz'
  import { generate_normal } from '$site/plot-utils'

  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({
    y: generate_normal(sample_size, 50, 15),
    label: `Normal Distribution (μ=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>

<label>Bins: {bins}<input type="range" bind:value={bins} min="5" max="200" /></label>
<label>Size: {sample_size}
  <input type="range" bind:value={sample_size} min="100" max="10000" step="100" />
</label>
<label><input type="checkbox" bind:checked={show_controls} />Controls</label>
<label>Radius: {border_radius}<input
    type="range"
    bind:value={border_radius}
    min="0"
    max="8"
  /></label>

{#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}
  bar={{ border_radius, stroke_color: `#364fc7`, stroke_width: 0.5 }}
  on_bar_hover={handle_bar_hover}
  on_bar_click={handle_bar_click}
  {tooltip}
  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 comparing distributions with vastly different sample sizes, use dual y-axes for independent scaling. This example shows test scores from two cohorts with 1000 vs 200 samples:

Test Score
Count (Main Cohort)
Count (Control)
Main Cohort (n=1000)
Control Group (n=200)
<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_normal } from '$site/plot-utils'

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

<div style="display: flex; gap: 1em; margin-bottom: 1em">
  <label><input type="checkbox" bind:checked={display.x_grid} />X grid</label>
  <label><input type="checkbox" bind:checked={display.y_grid} />Y1 grid</label>
  <label><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:

Value
Count (Normal/Uniform)
Count (Exp/Gamma)
Normal (μ=5, σ=2)
Exponential (λ=0.3)
Uniform (0-15)
Gamma (α=2, β=3)
<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '$site/plot-utils'

  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 })

  let series = $state([
    { y: utils.generate_normal(1200, 5, 2), label: `Normal (μ=5, σ=2)`, line_style: { stroke: `crimson` } },
    { y: utils.generate_exponential(1200, 0.3), label: `Exponential (λ=0.3)`, line_style: { stroke: `royalblue` }, y_axis: `y2` },
    { y: utils.generate_uniform(1200, 0, 15), label: `Uniform (0-15)`, line_style: { stroke: `mediumseagreen` } },
    { y: utils.generate_gamma(1000, 2, 3), label: `Gamma (α=2, β=3)`, line_style: { stroke: `darkorange` }, y_axis: `y2` },
  ])

  function toggle_series(idx: number): void {
    series[idx].visible = !series[idx].visible
    series = [...series]
  }
</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><input type="checkbox" bind:checked={display.x_grid} />X grid</label>
  <label><input type="checkbox" bind:checked={display.y_grid} />Y1 grid</label>
  <label><input type="checkbox" bind:checked={display.y2_grid} />Y2 grid</label>
</div>

{#each series as srs, idx (srs.label)}
  <label>
    <input type="checkbox" checked={srs.visible} onchange={() => toggle_series(idx)} />
    <span style="width: 16px; height: 16px; margin: 0 0.5em; background: {srs.line_style.stroke}"></span>
    {srs.label} {srs.y_axis === `y2` ? `(Y2)` : `(Y1)`}
  </label>
{/each}

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

Logarithmic Scales

X: Y:
Value
Count
Log-Normal (μ=2, σ=1)
Power Law (α=2.5)
Pareto (α=3)
<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '$site/plot-utils'

  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([
    {
      y: utils.generate_log_normal(1500, 2, 1),
      label: `Log-Normal (μ=2, σ=1)`,
      line_style: { stroke: `darkorange` },
    },
    {
      y: utils.generate_power_law(1500, 2.5),
      label: `Power Law (α=2.5)`,
      line_style: { stroke: `darkgreen` },
    },
    {
      y: utils.generate_pareto(1200, 1, 3),
      label: `Pareto (α=3)`,
      line_style: { stroke: `darkviolet` },
    },
  ])
</script>

X: {#each [`linear`, `log`] as scale (scale)}
  <label>
    <input type="radio" bind:group={x_axis.scale_type} value={scale} />{scale}
  </label>
{/each}
Y: {#each [`linear`, `log`] as scale (scale)}
  <label>
    <input type="radio" bind:group={y_axis.scale_type} value={scale} />{scale}
  </label>
{/each}

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

<Histogram
  {series}
  mode="overlay"
  {bins}
  {x_axis}
  {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

The arcsinh scale (scale_type='arcsinh') is perfect for data spanning both positive and negative values with large dynamic range. Unlike log scale, arcsinh handles zero and negative values smoothly, transitioning from linear behavior near zero to logarithmic behavior for large absolute values.

X-axis Scale
Y-axis Scale

Data spans -1000 to +1000 with concentration near zero. Try "log" to see it fail on negatives.

Value (arcsinh)
Count (linear)
<script lang="ts">
  import { Histogram } from 'matterviz'

  // Generate data with both positive and negative values
  function generate_mixed_data(n: number, seed = 42): number[] {
    const data: number[] = []
    let state = seed
    const rng = () => {
      state = (state * 1103515245 + 12345) & 0x7fffffff
      return state / 0x7fffffff
    }
    for (let idx = 0; idx < n; idx++) {
      // Mix of positive, negative, and near-zero values
      const u = rng()
      if (u < 0.4) {
        data.push((rng() - 0.5) * 20) // Near zero: -10 to +10
      } else if (u < 0.7) {
        data.push(rng() * 1000) // Positive: 0 to 1000
      } else {
        data.push(-rng() * 1000) // Negative: -1000 to 0
      }
    }
    return data
  }

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

  const mixed_data = generate_mixed_data(2000)

  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; gap: 2em; margin-bottom: 1em; flex-wrap: wrap">
  <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>
      Arcsinh Threshold: {arcsinh_threshold}
      <input type="range" bind:value={arcsinh_threshold} min="0.1" max="100" step="0.1" />
    </label>
  {/if}
</div>

<p style="font-size: 0.9em; opacity: 0.8">
  Data spans -1000 to +1000 with concentration near zero. Try "log" to see it fail on
  negatives.
</p>

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

Real-World Distributions

Value
Count
<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '$site/plot-utils'
  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`
      ? [{
        y: current.data,
        label: current.label,
        line_style: { stroke: current.color },
      }]
      : Object.entries(distributions).map(([key, dist]) => ({
        y: dist.data,
        label: dist.label,
        line_style: { stroke: 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>

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

<Histogram
  series={series_data}
  {mode}
  {x_axis}
  {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>

Bin Size Comparison

Value
Count
10 bins
25 bins
50 bins
100 bins
<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '$site/plot-utils'

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

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

  let series = $derived(
    show_overlay
      ? bin_counts.map((bins, idx) => ({
        y: base_data,
        label: `${bins} bins`,
        line_style: { stroke: colors[idx] },
      }))
      : [{ y: base_data, label: `${data_type === `mixed` ? `Mixed` : `Complex`} Distribution`, line_style: { stroke: `#8e44ad` } }]
  )
</script>

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

<label><input type="checkbox" bind:checked={show_overlay} />Multiple Bin Sizes</label>

<label>Opacity: {bar.opacity}<input type="range" bind:value={bar.opacity} min="0.1" max="1" step="0.1" /></label>

{#if !show_overlay}
  <label>Bins: {bin_counts[1]}<input type="range" bind:value={bin_counts[1]} min="5" max="200" step="5" /></label>
{:else}
  {#each bin_counts as count, idx (idx)}
    <label style="color: {colors[idx]}">{count} bins: <input type="range" bind:value={bin_counts[idx]} min="5" max="200" step="5" /></label>
  {/each}
{/if}

<Histogram
  {series}
  bins={show_overlay ? 25 : bin_counts[1]}
  mode={show_overlay ? `overlay` : `single`}
  bind:bar
  show_legend={show_overlay}
  style="height: 450px; margin-block: 1em;"
>
  {#snippet tooltip({ value, count, property })}
    <strong>{property}</strong><br>Range: {value.toFixed(1)}<br>Count: {count}
  {/snippet}
</Histogram>

Custom Styling

Value
Count
<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '$site/plot-utils'

  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` }

  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([{
    y: data,
    label: data_source === `financial` ? `Stock Prices` : `Scientific Measurements`,
    line_style: { stroke: color_schemes[color_scheme][0] },
  }])
</script>

{#each [`financial`, `scientific`] as source (source)}<label><input type="radio" bind:group={data_source} value={source} />{source}</label>{/each}

<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}
  {x_axis}
  {y_axis}
  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

Performance: normal distribution, 10,000 points, 50 bins, single mode
Value
Count
<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '$site/plot-utils'

  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`
      ? [{
        y: performance_data[data_type],
        label: `${data_type} (${dataset_size.toLocaleString()} points)`,
        line_style: { stroke: `#2c3e50` },
      }]
      : Object.entries(performance_data).map(([key, data]) => ({
        y: data,
        label: `${key} (${data.length.toLocaleString()} points)`,
        line_style: {
          stroke: 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:

Value
Count
Mean: 49.94 Median: 49.81 ±1σ
<script lang="ts">
  import { Histogram } from 'matterviz'
  import { generate_normal } from '$site/plot-utils'

  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((a, b) => a - b)
  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((s, v) => s + v, 0) / sample_a.length
  const mean_b = sample_b.reduce((s, v) => s + v, 0) / sample_b.length

  let series = $derived(
    comparison_mode
      ? [
        { y: sample_a, label: `Control`, line_style: { stroke: `#3498db` } },
        { y: sample_b, label: `Treatment`, line_style: { stroke: `#e74c3c` } },
      ]
      : [{
        y: data,
        label: `Normal Distribution`,
        line_style: { stroke: `#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

This demo stress-tests histograms with interactive property switching:

Points: 9,000 | Bins: 50 | Switches: 0 | Avg load: 0ms
Count
Oxides
Sulfides
Nitrides

9,000 samples across 3 material classes. Try rapidly switching properties. Current distribution type: normal

<script lang="ts">
  import { type DataSeries, Histogram } from 'matterviz'

  // Seeded random number generator
  function seeded_random(seed: number): () => number {
    let state = seed
    return () => {
      state = (state * 1103515245 + 12345) & 0x7fffffff
      return state / 0x7fffffff
    }
  }

  // Box-Muller transform for normal distribution
  function box_muller(rng: () => number): number {
    const u1 = rng()
    const u2 = rng()
    return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
  }

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

  // Generate various distributions
  function generate_distribution(type: DistType, n: number, seed: number): number[] {
    const rng = seeded_random(seed)
    const data: number[] = []

    for (let idx = 0; idx < n; idx++) {
      let val: number
      if (type === `normal`) {
        val = box_muller(rng) * 1.5 - 2
      } else if (type === `exponential`) {
        val = -Math.log(rng()) * 2
      } else if (type === `bimodal`) {
        val = rng() < 0.4 ? box_muller(rng) * 0.8 - 3 : box_muller(rng) * 1.2 + 2
      } else if (type === `uniform`) {
        val = rng() * 10 - 2
      } else if (type === `log-normal`) {
        val = Math.exp(box_muller(rng) * 0.8)
      } else if (type === `heavy-tail`) {
        val = box_muller(rng) / (rng() + 0.1)
      } else if (type === `skewed`) {
        const u = rng()
        val = Math.pow(u, 3) * 15 - 2
      } else {
        // multimodal
        const mode = Math.floor(rng() * 4)
        val = box_muller(rng) * 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((v) => v + idx * 0.5)
    })
  }

  type PropKey = keyof typeof property_configs

  // Build series for a property
  function build_series(prop_key: PropKey): DataSeries[] {
    return material_classes.map((name, idx) => ({
      y: all_data[prop_key][idx],
      label: name,
      line_style: { stroke: colors[idx], stroke_width: 2 },
      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: string,
    property_key: PropKey,
  ): Promise<{ series: DataSeries[]; axis_label: string }> {
    load_start = performance.now()
    await new Promise((r) => setTimeout(r, 100 + Math.random() * 400))
    const config = property_configs[property_key]
    return {
      series: build_series(property_key),
      axis_label: `${config.label} (${config.unit})`,
    }
  }

  function on_axis_change(_axis: string, property_key: PropKey): void {
    switch_count++
    current_prop = property_key
    bins = property_configs[property_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((a, b) => a + b, 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
  bind: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` }}
  {data_loader}
  {on_axis_change}
  bar={{ border_radius: 1 }}
  legend={{ layout: `horizontal`, style: `justify-content: center` }}
  style="height: 400px"
/>

<p style="margin-top: 0.5em; font-size: 0.8em; opacity: 0.7">
  {total_points.toLocaleString()} samples across 3 material classes. Try rapidly switching
  properties. Current distribution type: <code>{
    property_configs[current_prop].type
  }</code>
</p>

Multiple Plots in 2×2 Grid Layout

Display multiple histograms in a responsive 2×2 grid:

Normal Distribution

Value
Count

Exponential Distribution

Time
Count

Uniform Distribution

Random Value
Count

Gamma Distribution

Measurement
Count
<script lang="ts">
  import { Histogram } from 'matterviz'
  import * as utils from '$site/plot-utils'

  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={[{ y: data, line_style: { stroke: 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.

Y2 Sync:
Value
Count (A)
Count (B)
Sample A
Sample B
<script lang="ts">
  import { Histogram } from 'matterviz'

  const n_samples = 200
  // Normal-ish distribution (Box-Muller transform)
  const y1_values = Array.from({ length: n_samples }, () => {
    const [u1, u2] = [Math.random(), Math.random()]
    return 50 + 15 * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
  })
  // Exponential-ish distribution
  const y2_values = Array.from(
    { length: n_samples },
    () => 1000 + 500 * -Math.log(Math.random()),
  )

  const series = [
    {
      y: y1_values,
      label: `Sample A`,
      line_style: { stroke: `#e74c3c` },
      y_axis: `y1`,
    },
    {
      y: y2_values,
      label: `Sample B`,
      line_style: { stroke: `#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"
/>