adaptive-heatmap-ranges
v1.0.1
Published
Generate adaptive heatmap color ranges from numeric values with ranks, labels, and customizable colors. Detects natural gaps in data to prevent misleading color scaling.
Maintainers
Readme
adaptive-heatmap-ranges
Generate adaptive heatmap color ranges from numeric values with intelligent gap detection. Works with any chart library — ApexCharts, Chart.js, D3, or plain JavaScript.
Why This Package Exists
The Problem With Simple Min/Max Scaling
Most heatmap libraries color cells using naive min/max normalization. This means the smallest value gets green and the largest gets red, regardless of what those values actually represent.
This creates two major problems:
Problem 1: Small-only values get misleading colors
If your data is [0.03, 0.3, 0.6], naive scaling makes 0.6 appear red — but 0.6 isn't a high value in any practical context. It's still basically zero.
Problem 2: Mixed data with gaps
If your data is [0.03, 0.3, 0.6, 95, 100], there's a massive gap between 0.6 and 95. Naive scaling would paint the small values with intermediate colors, hiding the fact that they're all clustered near zero.
The Solution: Adaptive Range Generation
This package intelligently detects gaps and clusters in your data to generate meaningful color ranges. It doesn't just blindly split min-to-max — it understands the shape of your data.
Installation
npm install adaptive-heatmap-rangesyarn add adaptive-heatmap-rangespnpm add adaptive-heatmap-rangesQuick Start
import { generateHeatmapRanges, getRankForValue, toApexHeatmapRanges } from "adaptive-heatmap-ranges";
const values = [0.03, 0.3, 0.6, 95, 100];
const ranges = generateHeatmapRanges(values);
console.log(ranges);
// Find which range a value belongs to
const rank = getRankForValue(95, ranges);
// Convert for ApexCharts
const apexRanges = toApexHeatmapRanges(ranges);Real Output Examples
Every example below shows the actual output from the package. What you see is what you get.
1. Small Values: [0.03, 0.3, 0.6, 0.7, 1]
When all values are small, the package rounds up to a nice ceiling (1) and distributes ranges evenly across that space. No false "hot" colors.
generateHeatmapRanges([0.03, 0.3, 0.6, 0.7, 1])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 0.2 | 🟢 #2ECC71 |
| 2 | Low | 0.2001 | 0.4 | 🟢 #A3E635 |
| 3 | Medium | 0.4001 | 0.6 | 🟡 #FACC15 |
| 4 | High | 0.6001 | 0.8 | 🟠 #FB923C |
| 5 | Very High | 0.8001 | 1 | 🔴 #EF4444 |
Why it works: All gaps are roughly equal (~0.27–0.3), so no gap is "dominant." The adaptive algorithm falls back to nice-ceiling equal splits, giving a proper distribution across the 0–1 range.
2. Huge Gap: [0.03, 0.3, 0.6, 95, 100]
When there's a massive gap between clusters, the package detects it and isolates the low cluster into rank 1.
generateHeatmapRanges([0.03, 0.3, 0.6, 95, 100])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 0.6 | 🟢 #2ECC71 |
| 2 | Low | 0.6001 | 25.45 | 🟢 #A3E635 |
| 3 | Medium | 25.4501 | 50.3 | 🟡 #FACC15 |
| 4 | High | 50.3001 | 75.15 | 🟠 #FB923C |
| 5 | Very High | 75.1501 | 100 | 🔴 #EF4444 |
Why it works: The gap between 0.6 and 95 is 94.4, which is vastly larger than other gaps (0.27, 0.3, 5). The algorithm detects this dominant gap and places all values ≤ 0.6 in the green "Very Low" range. Values 95 and 100 correctly land in the red "Very High" range.
3. Normal Spread: [10, 20, 30, 40, 50]
Evenly distributed values get evenly distributed ranges.
generateHeatmapRanges([10, 20, 30, 40, 50])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 10 | 🟢 #2ECC71 |
| 2 | Low | 10.0001 | 20 | 🟢 #A3E635 |
| 3 | Medium | 20.0001 | 30 | 🟡 #FACC15 |
| 4 | High | 30.0001 | 40 | 🟠 #FB923C |
| 5 | Very High | 40.0001 | 50 | 🔴 #EF4444 |
Why it works: All gaps between values are equal (10 each). No dominant gap exists, so ranges spread from 0 to 50 (nice ceiling = 50) with even intervals.
4. Large Values: [500, 750, 900, 950, 1000]
Works the same way at any scale.
generateHeatmapRanges([500, 750, 900, 950, 1000])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 200 | 🟢 #2ECC71 |
| 2 | Low | 200.0001 | 400 | 🟢 #A3E635 |
| 3 | Medium | 400.0001 | 600 | 🟡 #FACC15 |
| 4 | High | 600.0001 | 800 | 🟠 #FB923C |
| 5 | Very High | 800.0001 | 1000 | 🔴 #EF4444 |
5. Mixed With Gap: [1, 2, 3, 4, 5, 80, 90, 100]
Two clusters with a clear gap between them.
generateHeatmapRanges([1, 2, 3, 4, 5, 80, 90, 100])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 5 | 🟢 #2ECC71 |
| 2 | Low | 5.0001 | 28.75 | 🟢 #A3E635 |
| 3 | Medium | 28.7501 | 52.5 | 🟡 #FACC15 |
| 4 | High | 52.5001 | 76.25 | 🟠 #FB923C |
| 5 | Very High | 76.2501 | 100 | 🔴 #EF4444 |
Why it works: The gap between 5 and 80 (= 75) is dominant over other gaps (1, 10, 10). Values 1–5 land in "Very Low" (green), while 80–100 land in "Very High" (red).
6. Very Small Decimals: [0.001, 0.005, 0.01, 0.02, 0.05]
Handles microscopic values correctly by scaling to the nice ceiling of 0.05.
generateHeatmapRanges([0.001, 0.005, 0.01, 0.02, 0.05])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 0.02 | 🟢 #2ECC71 |
| 2 | Low | 0.0201 | 0.0275 | 🟢 #A3E635 |
| 3 | Medium | 0.0276 | 0.035 | 🟡 #FACC15 |
| 4 | High | 0.0351 | 0.0425 | 🟠 #FB923C |
| 5 | Very High | 0.0426 | 0.05 | 🔴 #EF4444 |
7. Negative to Positive: [-50, -20, 0, 30, 80]
Works correctly with negative values — no forced zero baseline when values go below zero.
generateHeatmapRanges([-50, -20, 0, 30, 80])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | -50 | -20 | 🟢 #2ECC71 |
| 2 | Low | -19.9999 | 10 | 🟢 #A3E635 |
| 3 | Medium | 10.0001 | 40 | 🟡 #FACC15 |
| 4 | High | 40.0001 | 70 | 🟠 #FB923C |
| 5 | Very High | 70.0001 | 100 | 🔴 #EF4444 |
8. Single Value: [42]
Even a single value produces proper ranges using nice ceiling logic.
generateHeatmapRanges([42])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 10 | 🟢 #2ECC71 |
| 2 | Low | 10.0001 | 20 | 🟢 #A3E635 |
| 3 | Medium | 20.0001 | 30 | 🟡 #FACC15 |
| 4 | High | 30.0001 | 40 | 🟠 #FB923C |
| 5 | Very High | 40.0001 | 50 | 🔴 #EF4444 |
9. Duplicate Values: [5, 5, 5, 5]
Handles identical values safely — uses nice ceiling to create sensible ranges.
generateHeatmapRanges([5, 5, 5, 5])| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 1 | 🟢 #2ECC71 |
| 2 | Low | 1.0001 | 2 | 🟢 #A3E635 |
| 3 | Medium | 2.0001 | 3 | 🟡 #FACC15 |
| 4 | High | 3.0001 | 4 | 🟠 #FB923C |
| 5 | Very High | 4.0001 | 5 | 🔴 #EF4444 |
10. Equal Strategy: [10, 20, 30, 40, 50]
Simple equal-width intervals without gap detection.
generateHeatmapRanges([10, 20, 30, 40, 50], { strategy: "equal" })| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 10 | 🟢 #2ECC71 |
| 2 | Low | 10.0001 | 20 | 🟢 #A3E635 |
| 3 | Medium | 20.0001 | 30 | 🟡 #FACC15 |
| 4 | High | 30.0001 | 40 | 🟠 #FB923C |
| 5 | Very High | 40.0001 | 50 | 🔴 #EF4444 |
11. Quantile Strategy: [1, 1, 2, 2, 3, 50, 100]
Ranges based on data density — more data points in a region = tighter ranges there.
generateHeatmapRanges([1, 1, 2, 2, 3, 50, 100], { strategy: "quantile" })| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 1.2 | 🟢 #2ECC71 |
| 2 | Low | 1.2001 | 2 | 🟢 #A3E635 |
| 3 | Medium | 2.0001 | 2.6 | 🟡 #FACC15 |
| 4 | High | 2.6001 | 40.6 | 🟠 #FB923C |
| 5 | Very High | 40.6001 | 100 | 🔴 #EF4444 |
Why it works: Most data points (1, 1, 2, 2, 3) cluster at low values. Quantile strategy assigns more ranges to that dense region, while the sparse high values (50, 100) share the upper ranges.
12. Reverse Mode: [10, 20, 30, 40, 50]
When high values are good (e.g., adoption scores, satisfaction ratings), reverse makes low = red and high = green.
generateHeatmapRanges([10, 20, 30, 40, 50], {
reverse: true,
labels: ["Poor", "Below Average", "Average", "Good", "Excellent"]
})| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Excellent | 0 | 10 | 🔴 #EF4444 |
| 2 | Good | 10.0001 | 20 | 🟠 #FB923C |
| 3 | Average | 20.0001 | 30 | 🟡 #FACC15 |
| 4 | Below Average | 30.0001 | 40 | 🟢 #A3E635 |
| 5 | Poor | 40.0001 | 50 | 🟢 #2ECC71 |
13. Custom Colors & Labels: [10, 20, 30, 40, 50]
generateHeatmapRanges([10, 20, 30, 40, 50], {
colors: ["#00A65A", "#8CC152", "#F6BB42", "#E9573F", "#DA4453"],
labels: ["Safe", "Low", "Moderate", "Risk", "Critical"]
})| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Safe | 0 | 10 | #00A65A |
| 2 | Low | 10.0001 | 20 | #8CC152 |
| 3 | Moderate | 20.0001 | 30 | #F6BB42 |
| 4 | Risk | 30.0001 | 40 | #E9573F |
| 5 | Critical | 40.0001 | 50 | #DA4453 |
14. Custom Steps (3 ranges): [10, 20, 30, 40, 50]
generateHeatmapRanges([10, 20, 30, 40, 50], { steps: 3 })| Rank | Label | From | To | Color |
|------|-------|------|----|-------|
| 1 | Very Low | 0 | 16.6667 | 🟢 #2ECC71 |
| 2 | Low | 16.6668 | 33.3333 | 🟢 #A3E635 |
| 3 | Medium | 33.3334 | 50 | 🟡 #FACC15 |
How Adaptive Gap Detection Works
The default "adaptive" strategy is smarter than simple equal splitting. Here's the full algorithm:
Input: [0.03, 0.3, 0.6, 95, 100]
Step 1 — Sort & compute gaps between consecutive unique values:
0.03 → 0.3 = 0.27
0.3 → 0.6 = 0.3
0.6 → 95 = 94.4 ← LARGEST
95 → 100 = 5
Step 2 — Check if the largest gap is significant:
Threshold = max (100) × gapSensitivity (0.3) = 30
Is 94.4 > 30? → YES ✓
Step 3 — Check if the gap is dominant (not just noise):
Average of other gaps = (0.27 + 0.3 + 5) / 3 = 1.86
Is 94.4 > 1.86 × 3? → YES ✓ (50x larger!)
Step 4 — Split at the gap:
Low cluster: [0.03, 0.3, 0.6] → Rank 1 (green), range 0–0.6
Remaining: 0.6 to nice ceiling (100) split evenly into 4 ranges
Result:
Rank 1: 0 → 0.6 🟢 Very Low
Rank 2: 0.6001 → 25.45 🟢 Low
Rank 3: 25.4501 → 50.3 🟡 Medium
Rank 4: 50.3001 → 75.15 🟠 High
Rank 5: 75.1501 → 100 🔴 Very HighWhy the dominance check matters:
For [0.03, 0.3, 0.6, 0.7, 1], the gaps are 0.27, 0.3, 0.1, 0.3. The largest gap (0.3) is only 1.4× the average — it's not a real cluster boundary, just normal noise. So the algorithm falls back to equal splitting and gives you a proper 0–1 distribution.
API Reference
generateHeatmapRanges(values, options?)
Main function. Returns dynamic color ranges from numeric values.
function generateHeatmapRanges(
values: number[],
options?: HeatmapRangeOptions
): HeatmapRange[]Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| steps | number | 5 | Number of color ranges to generate |
| strategy | HeatmapStrategy | "adaptive" | "adaptive" | "equal" | "quantile" | "natural-breaks" |
| colors | string[] | Green-to-red | Custom hex color codes |
| labels | string[] | ["Very Low", ...] | Custom rank labels |
| min | number | Auto | Override minimum value |
| max | number | Auto | Override maximum value |
| roundTo | number | 4 | Decimal places for breakpoints |
| includeZeroBaseline | boolean | true | Start from 0 when all values ≥ 0 |
| gapSensitivity | number | 0.3 | Gap detection threshold (fraction of max) |
| nearZeroRatio | number | 0.3 | Near-zero cluster ratio |
| reverse | boolean | false | Reverse color/label order |
| clampOutliers | boolean | false | Clamp out-of-range values |
| fallbackMax | number | 1 | Max when all inputs are 0 or identical |
Output
interface HeatmapRange {
rank: number; // 1-based rank
label: string; // Human-readable label
from: number; // Range start (inclusive)
to: number; // Range end (inclusive)
color: string; // Hex color code
}getRankForValue(value, ranges)
Find which range a value belongs to.
const ranges = generateHeatmapRanges([0.03, 0.3, 0.6, 95, 100]);
getRankForValue(0.3, ranges);
// → { rank: 1, label: "Very Low", from: 0, to: 0.6, color: "#2ECC71" }
getRankForValue(95, ranges);
// → { rank: 5, label: "Very High", from: 75.1501, to: 100, color: "#EF4444" }
getRankForValue(NaN, ranges);
// → nulltoApexHeatmapRanges(ranges)
Convert to ApexCharts plotOptions.heatmap.colorScale.ranges format. Maps label → name.
const apexRanges = toApexHeatmapRanges(ranges);
// [{ from: 0, to: 0.6, name: "Very Low", color: "#2ECC71" }, ...]normalizeValues(values)
Clean an array of mixed types into sorted numbers. Filters out null, undefined, NaN, Infinity, and non-numeric values.
import { normalizeValues } from "adaptive-heatmap-ranges";
normalizeValues([0.03, null, "hello", NaN, 0.6, undefined, 95]);
// → [0.03, 0.6, 95]getNiceCeil(value)
Round up to a human-friendly ceiling using the 1-2-5-10 convention.
import { getNiceCeil } from "adaptive-heatmap-ranges";
getNiceCeil(0.3); // → 0.5
getNiceCeil(2.7); // → 5
getNiceCeil(8.4); // → 10
getNiceCeil(42); // → 50
getNiceCeil(95); // → 100
getNiceCeil(350); // → 500Strategies
| Strategy | Best For | Description |
|----------|----------|-------------|
| "adaptive" | Most use cases | Detects gaps between clusters; prevents misleading colors |
| "equal" | Uniform data | Equal-width intervals from min to max |
| "quantile" | Skewed data | Equal number of data points per range |
| "natural-breaks" | Clustered data | Jenks optimization — minimizes within-class variance |
ApexCharts Integration
import { generateHeatmapRanges, toApexHeatmapRanges } from "adaptive-heatmap-ranges";
const allValues = series.flatMap(row => row.data.map(point => point.y));
const ranges = generateHeatmapRanges(allValues, {
strategy: "adaptive",
steps: 5,
});
const options = {
chart: { type: "heatmap" },
plotOptions: {
heatmap: {
distributed: false,
enableShades: false,
colorScale: {
ranges: toApexHeatmapRanges(ranges),
},
},
},
};Framework Compatibility
Zero runtime dependencies. Works everywhere.
| Environment | Support |
|-------------|---------|
| Node.js | ✅ ESM & CommonJS |
| React / Vue / Angular / Svelte | ✅ |
| Plain Browser JS | ✅ ESM import |
| ApexCharts | ✅ via toApexHeatmapRanges() |
| Chart.js / D3 / any chart lib | ✅ use ranges directly |
TypeScript Support
Full type declarations included:
import type {
HeatmapRange,
HeatmapRangeOptions,
HeatmapStrategy,
ApexHeatmapRange,
} from "adaptive-heatmap-ranges";License
MIT
