chart-factory 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Shared Example Navigation
3
+ *
4
+ * Usage: Include this script and call initExampleNav() with the current page name.
5
+ *
6
+ * Example:
7
+ * <script src="../../src/core/example-nav.js"></script>
8
+ * <script>initExampleNav('scatter-plot', 'basic-example.html');</script>
9
+ */
10
+
11
+ const EXAMPLE_NAV_CONFIG = {
12
+ 'histogram-chart': [
13
+ { href: 'basic-example.html', label: 'Basic + Overlay' }
14
+ ],
15
+ 'donut-chart': [
16
+ { href: 'basic-example.html', label: 'Donut + Pie' }
17
+ ],
18
+ 'box-plot': [
19
+ { href: 'basic-example.html', label: 'Vertical + Horizontal' }
20
+ ],
21
+ 'calendar-heatmap': [
22
+ { href: 'basic-example.html', label: 'Three layouts' }
23
+ ],
24
+ 'sankey': [
25
+ { href: 'basic-example.html', label: 'Basic + Aligned' }
26
+ ],
27
+ 'scatter-plot': [
28
+ { href: 'basic-example.html', label: 'Basic' },
29
+ { href: 'logos-example.html', label: 'Logos' },
30
+ { href: 'animated-example.html', label: 'Animated' },
31
+ { href: 'trajectory-example.html', label: 'Trajectory' },
32
+ { href: 'comet-example.html', label: 'Comet' },
33
+ { href: 'dot-labels-example.html', label: 'Dot Labels' },
34
+ { href: 'beeswarm-example.html', label: 'Beeswarm' },
35
+ { href: 'beeswarm-vertical-example.html', label: 'Vertical' },
36
+ { href: 'beeswarm-logos-example.html', label: 'Swarm Logos' },
37
+ { href: 'beeswarm-horizontal-example.html', label: 'Horizontal' },
38
+ { href: 'heatmap-example.html', label: 'Heatmap' },
39
+ { href: 'hexbin-example.html', label: 'Hexbin' },
40
+ { href: 'contour-example.html', label: 'Contour' },
41
+ { href: 'quadrant-example.html', label: 'Quadrant' },
42
+ { href: 'diagonal-example.html', label: 'Diagonal' },
43
+ { href: 'marginal-example.html', label: 'Marginal' },
44
+ { href: 'marginal-simple-example.html', label: 'Simple' },
45
+ { href: 'kde-example.html', label: 'KDE' }
46
+ ],
47
+ 'line-chart': [
48
+ { href: 'showcase.html', label: 'Showcase' },
49
+ { href: 'basic-example.html', label: 'Basic' },
50
+ { href: 'yearly-example.html', label: 'Yearly' },
51
+ { href: 'smoothed-example.html', label: 'Smoothed' },
52
+ { href: 'combined-example.html', label: 'Combined' },
53
+ { href: 'yoy-example.html', label: 'YoY' },
54
+ { href: 'textures-example.html', label: 'Textures' },
55
+ { href: 'area-gradient-example.html', label: 'Area' },
56
+ { href: 'growth-example.html', label: 'Growth' },
57
+ { href: 'forecast-example.html', label: 'Forecast' },
58
+ { href: 'events-example.html', label: 'Events' },
59
+ { href: 'dynamic-band-example.html', label: 'Dynamic' },
60
+ { href: 'pulse-example.html', label: 'Pulse' },
61
+ { href: 'cumulative-vs-moving-example.html', label: 'Cumulative' },
62
+ { href: 'bump-example.html', label: 'Bump' }
63
+ ],
64
+ 'area-chart': [
65
+ { href: 'showcase.html', label: 'Showcase' },
66
+ { href: 'basic-example.html', label: 'Basic' },
67
+ { href: 'stacked-example.html', label: 'Stacked' },
68
+ { href: 'normalized-example.html', label: '100%' },
69
+ { href: 'smoothed-example.html', label: 'Smoothed' },
70
+ { href: 'diverging-example.html', label: 'Diverging' },
71
+ { href: 'streamgraph-example.html', label: 'Stream' }
72
+ ],
73
+ 'slope-chart': [
74
+ { href: 'showcase.html', label: 'Showcase' },
75
+ { href: 'basic-example.html', label: 'Trend' },
76
+ { href: 'categorical-example.html', label: 'Categorical' },
77
+ { href: 'highlight-example.html', label: 'Highlight' },
78
+ { href: 'animated-example.html', label: 'Animated' },
79
+ { href: 'bands-example.html', label: 'Bands' },
80
+ { href: 'indexed-example.html', label: 'Indexed' }
81
+ ],
82
+ 'dot-chart': [
83
+ { href: 'showcase.html', label: 'Showcase' },
84
+ { href: 'basic-example.html', label: 'Basic' },
85
+ { href: 'dumbbell-example.html', label: 'Dumbbell' },
86
+ { href: 'gradient-example.html', label: 'Gradient' },
87
+ { href: 'comet-example.html', label: 'Comet' },
88
+ { href: 'confidence-example.html', label: 'Confidence' },
89
+ { href: 'percentile-example.html', label: 'Percentile' },
90
+ { href: 'unit-histogram-example.html', label: 'Histogram' }
91
+ ],
92
+ 'bar-chart': [
93
+ { href: 'basic-example.html', label: 'Basic' },
94
+ { href: 'inline-example.html', label: 'Inline' },
95
+ { href: 'label-above-example.html', label: 'Label Above' },
96
+ { href: 'stacked-example.html', label: 'Stacked' },
97
+ { href: 'grouped-example.html', label: 'Grouped' },
98
+ { href: 'progress-example.html', label: 'Progress' },
99
+ { href: 'bullet-example.html', label: 'Bullet' },
100
+ { href: 'diverging-example.html', label: 'Diverging' },
101
+ { href: 'likert-example.html', label: 'Likert' },
102
+ { href: 'gantt-example.html', label: 'Gantt' },
103
+ { href: 'range-example.html', label: 'Range' },
104
+ { href: 'waterfall-example.html', label: 'Waterfall' },
105
+ { href: 'pyramid-example.html', label: 'Pyramid' },
106
+ { href: 'vertical-example.html', label: 'Vertical' },
107
+ { href: 'vertical-stacked-example.html', label: 'V-Stacked' },
108
+ { href: 'vertical-normalized-example.html', label: 'V-100%' }
109
+ ],
110
+ 'table': [
111
+ { href: 'showcase.html', label: 'Showcase' },
112
+ { href: 'percent-bar-example.html', label: 'Percent Bar' },
113
+ { href: 'sparkline-example.html', label: 'Sparkline' },
114
+ { href: 'logos-example.html', label: 'Logos & Images' },
115
+ { href: 'expandable-example.html', label: 'Expandable Rows' },
116
+ { href: 'sticky-example.html', label: 'Sticky, Caps & Totals' },
117
+ { href: 'column-groups-example.html', label: 'Column Groups' }
118
+ ]
119
+ };
120
+
121
+ /**
122
+ * Initialize the example navigation bar
123
+ * @param {string} chartType - The chart type folder name (e.g., 'scatter-plot')
124
+ * @param {string} currentPage - The current page filename (e.g., 'basic-example.html')
125
+ */
126
+ function initExampleNav(chartType, currentPage) {
127
+ // Check if we're in modal view - don't render nav, report height to parent
128
+ if (location.search.includes('modal=1')) {
129
+ document.body.classList.add('modal-view');
130
+ initModalSizeReporting();
131
+ return;
132
+ }
133
+
134
+ const config = EXAMPLE_NAV_CONFIG[chartType];
135
+ if (!config) {
136
+ console.warn(`No navigation config found for chart type: ${chartType}`);
137
+ return;
138
+ }
139
+
140
+ // Create nav element
141
+ const nav = document.createElement('nav');
142
+ nav.className = 'example-nav';
143
+
144
+ // Generate links
145
+ config.forEach(item => {
146
+ const link = document.createElement('a');
147
+ link.href = item.href;
148
+ link.textContent = item.label;
149
+ if (item.href === currentPage) {
150
+ link.className = 'active';
151
+ }
152
+ nav.appendChild(link);
153
+ });
154
+
155
+ // Insert at the beginning of body
156
+ document.body.insertBefore(nav, document.body.firstChild);
157
+
158
+ // Add body padding for fixed nav
159
+ document.body.style.paddingTop = '60px';
160
+ }
161
+
162
+ /**
163
+ * Enforce the modal layout with inline styles (belt and braces alongside
164
+ * body.modal-view CSS — inline styles win even over a stale cached
165
+ * stylesheet): the chart container must be the iframe's entire content.
166
+ * Must run BEFORE the page's chart script so the responsive chart renders at
167
+ * its final (unpadded) width on the first pass — no post-load size jump.
168
+ */
169
+ function applyModalLayout() {
170
+ const b = document.body.style;
171
+ b.setProperty('padding', '0', 'important');
172
+ b.setProperty('margin', '0', 'important');
173
+ b.setProperty('min-height', '0', 'important');
174
+ b.setProperty('display', 'block', 'important');
175
+ b.setProperty('overflow', 'hidden', 'important');
176
+ const container = document.querySelector('.chart-container');
177
+ if (container) {
178
+ container.style.setProperty('width', '100%', 'important');
179
+ container.style.setProperty('max-width', 'none', 'important');
180
+ container.style.setProperty('box-shadow', 'none', 'important');
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Modal size reporting: the parent showcase owns the iframe WIDTH; the child
186
+ * reports its rendered HEIGHT so the iframe can fit the chart. Charts are
187
+ * responsive, so they render at whatever width the parent gave the iframe.
188
+ */
189
+ function initModalSizeReporting() {
190
+ if (window.parent === window) return;
191
+
192
+ applyModalLayout();
193
+
194
+ const report = () => {
195
+ const container = document.querySelector('.chart-container') || document.body;
196
+ window.parent.postMessage({
197
+ type: 'chartSize',
198
+ height: Math.ceil(container.getBoundingClientRect().height)
199
+ }, '*');
200
+ };
201
+
202
+ // Layout is measurable synchronously; report now and again after the
203
+ // responsive first render / webfonts settle. (Deliberately timer-based:
204
+ // rAF can be throttled inside iframes.)
205
+ const start = () => {
206
+ report();
207
+ setTimeout(report, 150);
208
+ setTimeout(report, 500);
209
+ };
210
+ if (document.readyState === 'complete') {
211
+ start();
212
+ } else {
213
+ window.addEventListener('load', start);
214
+ }
215
+
216
+ // Re-report if content height settles later (fonts, debounced re-renders)
217
+ if (typeof ResizeObserver !== 'undefined') {
218
+ new ResizeObserver(() => report()).observe(document.body);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Auto-initialize if data attributes are present on script tag
224
+ * Usage: <script src="example-nav.js" data-chart-type="scatter-plot" data-current="basic-example.html"></script>
225
+ */
226
+ (function() {
227
+ // In modal mode, neutralize the standalone-page layout IMMEDIATELY —
228
+ // this script tag sits before the chart script, so the chart's first
229
+ // render happens at the final modal width (no resize jump after load).
230
+ if (location.search.includes('modal=1') && document.body) {
231
+ document.body.classList.add('modal-view');
232
+ applyModalLayout();
233
+ }
234
+
235
+ const scripts = document.getElementsByTagName('script');
236
+ const currentScript = scripts[scripts.length - 1];
237
+
238
+ const chartType = currentScript.getAttribute('data-chart-type');
239
+ const currentPage = currentScript.getAttribute('data-current');
240
+
241
+ if (chartType && currentPage) {
242
+ // Wait for DOM to be ready
243
+ if (document.readyState === 'loading') {
244
+ document.addEventListener('DOMContentLoaded', () => initExampleNav(chartType, currentPage));
245
+ } else {
246
+ initExampleNav(chartType, currentPage);
247
+ }
248
+ }
249
+ })();
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Color palettes for Chart Factory.
3
+ *
4
+ * A curated palette library organized by use case, with accessibility (CVD-safe)
5
+ * metadata and D3 scale helpers. Exposed on the factory as `ChartFactory.palettes`.
6
+ *
7
+ * A palette name is a plain string, so it drops into the option people already
8
+ * reach for: `colors: 'Set2'` (categorical → array) or `colorScheme: 'Viridis'`
9
+ * (sequential/diverging → interpolator). Configs stay pure JSON.
10
+ *
11
+ * ChartFactory.palettes.get('Set2') // ['#66c2a5', ...]
12
+ * ChartFactory.palettes.ordinalScale('Set2') // d3.scaleOrdinal
13
+ * ChartFactory.palettes.colorScale('Viridis', [0, 100]) // d3.scaleSequential
14
+ * ChartFactory.palettes.isColorblindSafe('Set2') // true
15
+ *
16
+ * Sequential/diverging ramps re-anchor their pale end toward the container
17
+ * surface under `[data-theme="dark"]` (mirroring the built-in --scale-* tokens),
18
+ * so a light-ended ramp doesn't glare on a dark background. Categorical palettes
19
+ * are distinct hues and pass through unchanged on both themes.
20
+ *
21
+ * @module chart-factory/core/palettes
22
+ */
23
+
24
+ import * as d3 from 'd3';
25
+ import { getCSSVar } from './d3-base.js';
26
+
27
+ // =============================================================================
28
+ // SEQUENTIAL — ordered continuous data (heatmaps, density, intensity)
29
+ // =============================================================================
30
+
31
+ export const SEQUENTIAL_PALETTES = {
32
+ Viridis: ['#fde725','#dde318','#bade28','#95d840','#75d054','#56c667','#3dbc74','#29af7f','#20a386','#1f968b','#238a8d','#287d8e','#2d718e','#33638d','#39558c','#404688','#453781','#482576','#481467','#440154'],
33
+ Inferno: ['#fcffa4','#f1ed71','#f6d543','#fbba1f','#fca108','#f8870e','#f1711f','#e55c30','#d74b3f','#c43c4e','#b1325a','#9b2964','#87216b','#71196e','#5c126e','#450a69','#2f0a5b','#180c3c','#08051d','#000004'],
34
+ Magma: ['#fcfdbf','#fde5a7','#fecd90','#feb47b','#fd9b6b','#fa815f','#f4695c','#e85362','#d6456c','#c03a76','#ab337c','#942c80','#802582','#6a1c81','#56147d','#3f0f72','#29115a','#150e38','#07061c','#000004'],
35
+ Plasma: ['#f0f921','#f7e225','#fccd25','#feb72d','#fca338','#f79044','#f07f4f','#e76e5b','#dd5e66','#d14e72','#c5407e','#b6308b','#a72197','#9511a1','#8305a7','#6e00a8','#5901a5','#43039e','#2c0594','#0d0887'],
36
+ Blues: ['#f7fbff','#deebf7','#c6dbef','#9ecae1','#6baed6','#4292c6','#2171b5','#08519c','#08306b'],
37
+ BluesDark: ['#08306b','#08519c','#2171b5','#4292c6','#6baed6','#9ecae1','#c6dbef','#deebf7','#f7fbff'],
38
+ Greens: ['#f7fcf5','#e5f5e0','#c7e9c0','#a1d99b','#74c476','#41ab5d','#238b45','#006d2c','#00441b'],
39
+ Greys: ['#ffffff','#f0f0f0','#d9d9d9','#bdbdbd','#969696','#737373','#525252','#252525','#000000'],
40
+ Oranges: ['#fff5eb','#fee6ce','#fdd0a2','#fdae6b','#fd8d3c','#f16913','#d94801','#a63603','#7f2704'],
41
+ Purples: ['#fcfbfd','#efedf5','#dadaeb','#bcbddc','#9e9ac8','#807dba','#6a51a3','#54278f','#3f007d'],
42
+ Reds: ['#fff5f0','#fee0d2','#fcbba1','#fc9272','#fb6a4a','#ef3b2c','#cb181d','#a50f15','#67000d'],
43
+ BuGn: ['#f7fcfd','#e5f5f9','#ccece6','#99d8c9','#66c2a4','#41ae76','#238b45','#006d2c','#00441b'],
44
+ BuPu: ['#f7fcfd','#e0ecf4','#bfd3e6','#9ebcda','#8c96c6','#8c6bb1','#88419d','#810f7c','#4d004b'],
45
+ GnBu: ['#f7fcf0','#e0f3db','#ccebc5','#a8ddb5','#7bccc4','#4eb3d3','#2b8cbe','#0868ac','#084081'],
46
+ OrRd: ['#fff7ec','#fee8c8','#fdd49e','#fdbb84','#fc8d59','#ef6548','#d7301f','#b30000','#7f0000'],
47
+ PuBu: ['#fff7fb','#ece7f2','#d0d1e6','#a6bddb','#74a9cf','#3690c0','#0570b0','#045a8d','#023858'],
48
+ PuBuGn: ['#fff7fb','#ece2f0','#d0d1e6','#a6bddb','#67a9cf','#3690c0','#02818a','#016c59','#014636'],
49
+ PuRd: ['#f7f4f9','#e7e1ef','#d4b9da','#c994c7','#df65b0','#e7298a','#ce1256','#980043','#67001f'],
50
+ RdPu: ['#fff7f3','#fde0dd','#fcc5c0','#fa9fb5','#f768a1','#dd3497','#ae017e','#7a0177','#49006a'],
51
+ YlGn: ['#ffffe5','#f7fcb9','#d9f0a3','#addd8e','#78c679','#41ab5d','#238443','#006837','#004529'],
52
+ YlGnBu: ['#ffffd9','#edf8b1','#c7e9b4','#7fcdbb','#41b6c4','#1d91c0','#225ea8','#253494','#081d58'],
53
+ YlOrBr: ['#ffffe5','#fff7bc','#fee391','#fec44f','#fe9929','#ec7014','#cc4c02','#993404','#662506'],
54
+ YlOrRd: ['#ffffcc','#ffeda0','#fed976','#feb24c','#fd8d3c','#fc4e2a','#e31a1c','#bd0026','#800026'],
55
+ Spectral: ['#9e0142','#d53e4f','#f46d43','#fdae61','#fee08b','#ffffbf','#e6f598','#abdda4','#66c2a5','#3288bd','#5e4fa2'],
56
+ };
57
+
58
+ // =============================================================================
59
+ // DIVERGING — data with a meaningful midpoint (positive vs negative)
60
+ // =============================================================================
61
+
62
+ export const DIVERGING_PALETTES = {
63
+ BrBG: ['#543005','#8c510a','#bf812d','#dfc27d','#f6e8c3','#f5f5f5','#c7eae5','#80cdc1','#35978f','#01665e','#003c30'],
64
+ PRGn: ['#40004b','#762a83','#9970ab','#c2a5cf','#e7d4e8','#f7f7f7','#d9f0d3','#a6dba0','#5aae61','#1b7837','#00441b'],
65
+ PiYG: ['#8e0152','#c51b7d','#de77ae','#f1b6da','#fde0ef','#f7f7f7','#e6f5d0','#b8e186','#7fbc41','#4d9221','#276419'],
66
+ PuOr: ['#7f3b08','#b35806','#e08214','#fdb863','#fee0b6','#f7f7f7','#d8daeb','#b2abd2','#8073ac','#542788','#2d004b'],
67
+ RdBu: ['#67001f','#b2182b','#d6604d','#f4a582','#fddbc7','#f7f7f7','#d1e5f0','#92c5de','#4393c3','#2166ac','#053061'],
68
+ RdGy: ['#67001f','#b2182b','#d6604d','#f4a582','#fddbc7','#ffffff','#e0e0e0','#bababa','#878787','#4d4d4d','#1a1a1a'],
69
+ RdYlBu: ['#a50026','#d73027','#f46d43','#fdae61','#fee090','#ffffbf','#e0f3f8','#abd9e9','#74add1','#4575b4','#313695'],
70
+ RdYlGn: ['#a50026','#d73027','#f46d43','#fdae61','#fee08b','#ffffbf','#d9ef8b','#a6d96a','#66bd63','#1a9850','#006837'],
71
+ Temperature: ['#7ab8ff','#9ccaff','#b8d9ff','#d6e8ff','#f2f7ff','#fff1c1','#ffd27f','#ffb347','#ff8c1a','#ff6a00'],
72
+ MaroonMidnight: ['#8C1932','#FF6437','#E8E8E8','#509BF5','#1E3264'],
73
+ SunsetOcean: ['#FF6437','#F98563','#F78C6C','#EDC7BB','#E8E8E8','#C2D4EB','#8EBAEE','#76AEF1','#509BF5'],
74
+ MaroonForest: ['#8C1932','#FF6437','#E8E8E8','#4B917D','#006450'],
75
+ };
76
+
77
+ // =============================================================================
78
+ // CATEGORICAL — nominal categories with no inherent order
79
+ // =============================================================================
80
+
81
+ export const CATEGORICAL_PALETTES = {
82
+ Set1: ['#e41a1c','#377eb8','#4daf4a','#984ea3','#ff7f00','#ffff33','#a65628','#f781bf','#999999'],
83
+ Set2: ['#66c2a5','#fc8d62','#8da0cb','#e78ac3','#a6d854','#ffd92f','#e5c494','#b3b3b3'],
84
+ Set3: ['#8dd3c7','#ffffb3','#bebada','#fb8072','#80b1d3','#fdb462','#b3de69','#fccde5','#d9d9d9','#bc80bd','#ccebc5','#ffed6f'],
85
+ Paired: ['#a6cee3','#1f78b4','#b2df8a','#33a02c','#fb9a99','#e31a1c','#fdbf6f','#ff7f00','#cab2d6','#6a3d9a','#ffff99','#b15928'],
86
+ Dark2: ['#1b9e77','#d95f02','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#666666'],
87
+ Accent: ['#7fc97f','#beaed4','#fdc086','#ffff99','#386cb0','#f0027f','#bf5b17','#666666'],
88
+ Pastel1: ['#fbb4ae','#b3cde3','#ccebc5','#decbe4','#fed9a6','#ffffcc','#e5d8bd','#fddaec','#f2f2f2'],
89
+ Pastel2: ['#b3e2cd','#fdcdac','#cbd5e8','#f4cae4','#e6f5c9','#fff2ae','#f1e2cc','#cccccc'],
90
+ ChartDefault: ['#3b82f6','#22c55e','#f97316','#a855f7','#ef4444'],
91
+ OkabeIto: ['#E69F00','#56B4E9','#009E73','#F0E442','#0072B2','#D55E00','#CC79A7','#000000'],
92
+ Status: ['#22c55e','#f59e0b','#ef4444','#3b82f6'],
93
+ Binary: ['#3b82f6','#e5e7eb'],
94
+ };
95
+
96
+ // =============================================================================
97
+ // ALERT — highlight values at one end of the scale
98
+ // =============================================================================
99
+
100
+ export const ALERT_PALETTES = {
101
+ AquaAlert: ['#E8E8E8','#b4b4b4','#4100F5'],
102
+ OrangeAlert: ['#E8E8E8','#b4b4b4','#FF4632'],
103
+ SalmonAlert: ['#E8E8E8','#b4b4b4','#F037A5'],
104
+ ForestAlert: ['#E8E8E8','#b4b4b4','#19E68C'],
105
+ };
106
+
107
+ // =============================================================================
108
+ // COUNTRY COLORS — consistent per-country colors, loosely inspired by flags
109
+ // =============================================================================
110
+
111
+ export const COUNTRY_COLORS = {
112
+ US: '#1f78b4', CA: '#e31a1c', GB: '#a6cee3', UK: '#a6cee3', BR: '#33a02c',
113
+ DE: '#ff7f00', MX: '#eab308', FR: '#6a3d9a', JP: '#fb9a99', AU: '#cab2d6',
114
+ IT: '#b2df8a', ES: '#fdbf6f', IN: '#b15928', CN: '#c41e3a', NL: '#ff7f00',
115
+ SE: '#1f78b4', NO: '#e31a1c', CH: '#e31a1c', AT: '#e31a1c', PL: '#e31a1c',
116
+ BE: '#eab308', KR: '#a6cee3', AR: '#a6cee3', RoW: '#6b7280', ROW: '#6b7280',
117
+ };
118
+
119
+ // Unique country colors only (for legends), exposed in ALL_PALETTES as "Countries"
120
+ export const COUNTRY_PALETTE = [
121
+ '#1f78b4','#e31a1c','#a6cee3','#33a02c','#ff7f00','#eab308','#6a3d9a',
122
+ '#fb9a99','#cab2d6','#b2df8a','#fdbf6f','#b15928','#c41e3a','#6b7280',
123
+ ];
124
+
125
+ // =============================================================================
126
+ // COMBINED LOOKUP + METADATA
127
+ // =============================================================================
128
+
129
+ export const ALL_PALETTES = {
130
+ ...SEQUENTIAL_PALETTES,
131
+ ...DIVERGING_PALETTES,
132
+ ...CATEGORICAL_PALETTES,
133
+ ...ALERT_PALETTES,
134
+ Countries: COUNTRY_PALETTE,
135
+ };
136
+
137
+ /** @type {Record<string, {type:'sequential'|'diverging'|'categorical'|'alert', tags:string[], colorblindSafe:boolean, maxCategories?:number, description:string}>} */
138
+ export const PALETTE_METADATA = {
139
+ Viridis: { type: 'sequential', tags: ['heatmap','density','continuous','perceptually-uniform','scientific'], colorblindSafe: true, description: 'Perceptually uniform, colorblind-safe. Best for scientific visualization.' },
140
+ Inferno: { type: 'sequential', tags: ['heatmap','density','continuous','perceptually-uniform','dark-theme','intensity'], colorblindSafe: true, description: 'High contrast, works well on dark backgrounds.' },
141
+ Magma: { type: 'sequential', tags: ['heatmap','density','continuous','perceptually-uniform','warm'], colorblindSafe: true, description: 'Warm tones, good for density plots and heatmaps.' },
142
+ Plasma: { type: 'sequential', tags: ['heatmap','density','continuous','perceptually-uniform','vibrant'], colorblindSafe: true, description: 'Vibrant purple-to-yellow, high visual impact.' },
143
+ Blues: { type: 'sequential', tags: ['single-hue','water','cold','corporate','neutral','print-friendly'], colorblindSafe: true, description: 'Classic blue gradient. Safe, professional choice.' },
144
+ BluesDark: { type: 'sequential', tags: ['single-hue','dark-mode','dark-background','inverted'], colorblindSafe: true, description: 'Inverted Blues for dark backgrounds (dark to light).' },
145
+ Greens: { type: 'sequential', tags: ['single-hue','nature','growth','environmental','positive','print-friendly'], colorblindSafe: true, description: 'Nature/growth associations. Good for environmental data.' },
146
+ Greys: { type: 'sequential', tags: ['single-hue','neutral','monochrome','print-friendly','background'], colorblindSafe: true, description: 'Neutral grayscale. Best for secondary/background data.' },
147
+ Oranges: { type: 'sequential', tags: ['single-hue','warm','energy','attention','print-friendly'], colorblindSafe: true, description: 'Warm orange tones. Good for energy/activity metrics.' },
148
+ Purples: { type: 'sequential', tags: ['single-hue','elegant','premium','creative','print-friendly'], colorblindSafe: true, description: 'Elegant purple gradient. Good for premium/creative contexts.' },
149
+ Reds: { type: 'sequential', tags: ['single-hue','heat','intensity','danger','attention','print-friendly'], colorblindSafe: false, description: 'Intensity/heat associations. Use sparingly (red-green confusion).' },
150
+ BuGn: { type: 'sequential', tags: ['multi-hue','water','nature','cool'], colorblindSafe: true, description: 'Blue to green. Good for water/environmental data.' },
151
+ BuPu: { type: 'sequential', tags: ['multi-hue','cool','elegant'], colorblindSafe: true, description: 'Blue to purple. Cool, elegant progression.' },
152
+ GnBu: { type: 'sequential', tags: ['multi-hue','nature','water','cool'], colorblindSafe: true, description: 'Green to blue. Nature-to-water transition.' },
153
+ OrRd: { type: 'sequential', tags: ['multi-hue','warm','heat','intensity'], colorblindSafe: false, description: 'Orange to red. Heat/intensity scale.' },
154
+ PuBu: { type: 'sequential', tags: ['multi-hue','cool','calm'], colorblindSafe: true, description: 'Purple to blue. Cool, calming tones.' },
155
+ PuBuGn: { type: 'sequential', tags: ['multi-hue','cool','nature'], colorblindSafe: true, description: 'Purple to blue to green. Wide cool range.' },
156
+ PuRd: { type: 'sequential', tags: ['multi-hue','warm','intensity'], colorblindSafe: false, description: 'Purple to red. Intensity with warm endpoint.' },
157
+ RdPu: { type: 'sequential', tags: ['multi-hue','warm','feminine'], colorblindSafe: false, description: 'Red to purple. Warm, vibrant tones.' },
158
+ YlGn: { type: 'sequential', tags: ['multi-hue','nature','growth','positive'], colorblindSafe: true, description: 'Yellow to green. Growth/nature associations.' },
159
+ YlGnBu: { type: 'sequential', tags: ['multi-hue','nature','water','versatile'], colorblindSafe: true, description: 'Yellow to green to blue. Versatile, natural feel.' },
160
+ YlOrBr: { type: 'sequential', tags: ['multi-hue','warm','earth','desert'], colorblindSafe: true, description: 'Yellow to orange to brown. Earth/desert tones.' },
161
+ YlOrRd: { type: 'sequential', tags: ['multi-hue','warm','heat','intensity','fire'], colorblindSafe: false, description: 'Yellow to orange to red. Fire/heat associations.' },
162
+ Spectral: { type: 'sequential', tags: ['multi-hue','rainbow','full-spectrum','scientific'], colorblindSafe: false, description: 'Full spectrum rainbow. Use for specific scientific contexts.' },
163
+ BrBG: { type: 'diverging', tags: ['earth','nature','land-water','environmental'], colorblindSafe: true, description: 'Brown to blue-green. Land vs water, dry vs wet.' },
164
+ PRGn: { type: 'diverging', tags: ['purple-green','contrast','neutral-midpoint'], colorblindSafe: true, description: 'Purple to green. High contrast diverging scale.' },
165
+ PiYG: { type: 'diverging', tags: ['pink-green','contrast','vibrant'], colorblindSafe: true, description: 'Pink to yellow-green. Vibrant diverging scale.' },
166
+ PuOr: { type: 'diverging', tags: ['purple-orange','warm-cool','contrast'], colorblindSafe: true, description: 'Purple to orange. Strong warm/cool contrast.' },
167
+ RdBu: { type: 'diverging', tags: ['red-blue','hot-cold','classic','political'], colorblindSafe: false, description: 'Red to blue. Classic hot/cold, also political mapping.' },
168
+ RdGy: { type: 'diverging', tags: ['red-gray','positive-neutral','muted'], colorblindSafe: false, description: 'Red to gray. Good when one direction is "neutral".' },
169
+ RdYlBu: { type: 'diverging', tags: ['red-yellow-blue','traffic-light','intuitive'], colorblindSafe: false, description: 'Red to yellow to blue. Intuitive bad-neutral-good.' },
170
+ RdYlGn: { type: 'diverging', tags: ['red-yellow-green','traffic-light','performance','intuitive'], colorblindSafe: false, description: 'Red to yellow to green. Traffic light / performance scale.' },
171
+ Temperature: { type: 'diverging', tags: ['temperature','kelvin','cold-warm','percentile','custom'], colorblindSafe: true, description: 'Kelvin-inspired cold blue to warm amber. Good for percentiles.' },
172
+ MaroonMidnight: { type: 'diverging', tags: ['warm-cool','maroon-blue','contrast','editorial'], colorblindSafe: false, description: 'Maroon to midnight blue through neutral gray.' },
173
+ SunsetOcean: { type: 'diverging', tags: ['warm-cool','orange-blue','granular','editorial'], colorblindSafe: true, description: '9-step warm orange to cool blue through neutral gray.' },
174
+ MaroonForest: { type: 'diverging', tags: ['warm-green','maroon-forest','contrast','editorial'], colorblindSafe: false, description: 'Maroon to forest green through neutral gray.' },
175
+ Set1: { type: 'categorical', tags: ['bold','saturated','high-contrast','colorbrewer'], colorblindSafe: false, maxCategories: 9, description: 'Bold, saturated colors. High visual impact.' },
176
+ Set2: { type: 'categorical', tags: ['muted','pastel','area-fills','soft','colorbrewer','house-default'], colorblindSafe: true, maxCategories: 8, description: 'Softer, muted colors. Good for filled areas. Chart Factory house default.' },
177
+ Set3: { type: 'categorical', tags: ['pastel','light','many-categories','colorbrewer'], colorblindSafe: false, maxCategories: 12, description: 'Light pastels. Use when you need many categories.' },
178
+ Paired: { type: 'categorical', tags: ['paired','light-dark','grouped','comparison','colorbrewer'], colorblindSafe: true, maxCategories: 12, description: 'Light/dark pairs. Great for grouped comparisons.' },
179
+ Dark2: { type: 'categorical', tags: ['bold','saturated','dark','high-contrast','colorbrewer'], colorblindSafe: true, maxCategories: 8, description: 'Bold, dark colors. Good contrast on light backgrounds.' },
180
+ Accent: { type: 'categorical', tags: ['accent','highlight','mixed','colorbrewer'], colorblindSafe: false, maxCategories: 8, description: 'Mixed saturation. Good for highlights.' },
181
+ Pastel1: { type: 'categorical', tags: ['pastel','soft','light','feminine','colorbrewer'], colorblindSafe: false, maxCategories: 9, description: 'Very soft pastels. Low contrast, gentle aesthetic.' },
182
+ Pastel2: { type: 'categorical', tags: ['pastel','soft','light','colorbrewer'], colorblindSafe: false, maxCategories: 8, description: 'Soft pastels. Similar to Pastel1, slightly different hues.' },
183
+ ChartDefault: { type: 'categorical', tags: ['chart-factory','balanced','general-purpose','legacy'], colorblindSafe: false, maxCategories: 5, description: 'Legacy Chart Factory line palette (blue/green/orange/purple/red).' },
184
+ OkabeIto: { type: 'categorical', tags: ['colorblind-safe','accessibility','scientific','inclusive'], colorblindSafe: true, maxCategories: 8, description: 'Okabe-Ito colorblind-safe palette. Best for accessibility.' },
185
+ Status: { type: 'categorical', tags: ['semantic','status','success-warning-error','ui'], colorblindSafe: false, maxCategories: 4, description: 'Semantic status: success, warning, error, info.' },
186
+ Binary: { type: 'categorical', tags: ['binary','boolean','yes-no','simple','toggle'], colorblindSafe: true, maxCategories: 2, description: 'Simple yes/no, true/false, on/off visualization.' },
187
+ Countries: { type: 'categorical', tags: ['geo','countries','markets','flags','regional'], colorblindSafe: false, maxCategories: 14, description: 'Country-specific colors loosely inspired by flags.' },
188
+ AquaAlert: { type: 'alert', tags: ['alert','action','urgent','purple','attention'], colorblindSafe: true, description: 'Neutral to purple. Action required / urgent items.' },
189
+ OrangeAlert: { type: 'alert', tags: ['alert','warning','danger','red','attention'], colorblindSafe: false, description: 'Neutral to red. Warnings and danger indicators.' },
190
+ SalmonAlert: { type: 'alert', tags: ['alert','attention','highlight','pink'], colorblindSafe: true, description: 'Neutral to pink. General attention/highlight.' },
191
+ ForestAlert: { type: 'alert', tags: ['alert','positive','success','green','good'], colorblindSafe: true, description: 'Neutral to green. Positive outcomes / success.' },
192
+ };
193
+
194
+ // =============================================================================
195
+ // HELPERS
196
+ // =============================================================================
197
+
198
+ /** Get a palette's color array by name (undefined if unknown). */
199
+ export function getPalette(name) {
200
+ return ALL_PALETTES[name];
201
+ }
202
+
203
+ /** Palette type ('sequential' | 'diverging' | 'categorical' | 'alert' | undefined). */
204
+ export function paletteType(name) {
205
+ return PALETTE_METADATA[name]?.type;
206
+ }
207
+
208
+ /** List palette names, optionally filtered by type. */
209
+ export function listPalettes(type) {
210
+ const names = Object.keys(ALL_PALETTES);
211
+ return type ? names.filter(n => paletteType(n) === type) : names;
212
+ }
213
+
214
+ /** Whether a palette is safe for color vision deficiency. */
215
+ export function isColorblindSafe(name) {
216
+ return PALETTE_METADATA[name]?.colorblindSafe ?? false;
217
+ }
218
+
219
+ /** Sample n evenly-spaced colors from a palette (name or array). */
220
+ export function samplePalette(palette, n) {
221
+ const colors = typeof palette === 'string' ? getPalette(palette) : palette;
222
+ if (!colors) return [];
223
+ if (n >= colors.length) return [...colors];
224
+ if (n <= 1) return [colors[0]];
225
+ const out = [];
226
+ for (let i = 0; i < n; i++) {
227
+ out.push(colors[Math.round((i * (colors.length - 1)) / (n - 1))]);
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /** Consistent per-country color (case-insensitive, trimmed). */
233
+ export function getCountryColor(code, fallback = '#6b7280') {
234
+ if (!code) return fallback;
235
+ return COUNTRY_COLORS[code.toUpperCase().trim()] || fallback;
236
+ }
237
+
238
+ // --- Dark-mode ramp re-anchoring --------------------------------------------
239
+ // Sequential/diverging ramps carry a pale end (or a light diverging center).
240
+ // On dark backgrounds those glare, so pull the light stops toward the container
241
+ // surface — the more a stop's lightness exceeds the surface, the harder it is
242
+ // pulled. Categorical palettes are NOT re-anchored: they are distinct hues and
243
+ // blending them toward the surface would muddy them. Mirrors how the built-in
244
+ // --scale-* lo-ends re-anchor under [data-theme="dark"].
245
+
246
+ function isDarkTheme() {
247
+ return typeof document !== 'undefined'
248
+ && document.documentElement.getAttribute('data-theme') === 'dark';
249
+ }
250
+
251
+ /**
252
+ * Return a palette's colors, re-anchored for the current theme.
253
+ * Categorical palettes (and light themes) pass through unchanged.
254
+ */
255
+ export function themeRamp(colors, name) {
256
+ if (!Array.isArray(colors) || !isDarkTheme()) return colors;
257
+ if (name && paletteType(name) === 'categorical') return colors;
258
+ const surface = getCSSVar('--color-bg-container', '#171b22');
259
+ const sL = d3.lab(surface).l;
260
+ return colors.map(c => {
261
+ const cl = d3.lab(c).l;
262
+ if (cl <= sL + 12) return c; // already dark enough
263
+ const t = Math.min(0.72, ((cl - sL) / 100) * 0.95);
264
+ return d3.interpolateLab(c, surface)(t);
265
+ });
266
+ }
267
+
268
+ /** A palette's colors as a theme-aware ramp (by name). */
269
+ export function paletteRamp(name) {
270
+ const colors = getPalette(name);
271
+ return colors ? themeRamp(colors, name) : undefined;
272
+ }
273
+
274
+ // --- D3 scale factories ------------------------------------------------------
275
+
276
+ /** Ordinal scale over a categorical palette (raw hues, no re-anchor). */
277
+ export function ordinalScale(palette, domain) {
278
+ const colors = typeof palette === 'string' ? getPalette(palette) : palette;
279
+ const scale = d3.scaleOrdinal().range(colors || []);
280
+ if (domain) scale.domain(domain);
281
+ return scale;
282
+ }
283
+
284
+ /**
285
+ * Color scale from a palette. Categorical → ordinal; sequential/diverging/alert
286
+ * → sequential interpolator over the theme-aware ramp.
287
+ */
288
+ export function colorScale(palette, domain = [0, 1]) {
289
+ const name = typeof palette === 'string' ? palette : undefined;
290
+ const colors = name ? getPalette(name) : palette;
291
+ if (!colors) throw new Error(`Unknown palette: ${palette}`);
292
+ if (name && paletteType(name) === 'categorical') return ordinalScale(name);
293
+ return d3.scaleSequential(d3.interpolateRgbBasis(themeRamp(colors, name))).domain(domain);
294
+ }
295
+
296
+ // Public surface bundled onto ChartFactory.palettes
297
+ export const palettes = {
298
+ all: ALL_PALETTES,
299
+ metadata: PALETTE_METADATA,
300
+ countryColors: COUNTRY_COLORS,
301
+ get: getPalette,
302
+ type: paletteType,
303
+ list: listPalettes,
304
+ isColorblindSafe,
305
+ sample: samplePalette,
306
+ ramp: paletteRamp,
307
+ ordinalScale,
308
+ colorScale,
309
+ getCountryColor,
310
+ };