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>