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
+ * Chart Factory - Shared Utilities
3
+ *
4
+ * Common utilities used across all D3 visualization components.
5
+ *
6
+ * @requires d3 v7+ (peer dependency)
7
+ */
8
+
9
+ import * as d3 from 'd3';
10
+
11
+ /**
12
+ * Get CSS variable value with fallback
13
+ * @param {string} name - CSS variable name (e.g., '--font-family')
14
+ * @param {string} fallback - Fallback value if variable not found
15
+ * @returns {string} The CSS variable value or fallback
16
+ */
17
+ export function getCSSVar(name, fallback = '') {
18
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
19
+ return value || fallback;
20
+ }
21
+
22
+ /**
23
+ * Text measurement utility class
24
+ * Creates a hidden DOM element to measure text width accurately
25
+ */
26
+ export class TextMeasurer {
27
+ constructor() {
28
+ this.element = null;
29
+ }
30
+
31
+ /**
32
+ * Initialize the hidden measurement element
33
+ * @private
34
+ */
35
+ init() {
36
+ if (!this.element) {
37
+ this.element = d3.select('body')
38
+ .append('div')
39
+ .style('position', 'absolute')
40
+ .style('visibility', 'hidden')
41
+ .style('white-space', 'nowrap')
42
+ .style('font-family', getCSSVar('--font-family', 'sans-serif'))
43
+ .style('font-size', getCSSVar('--font-size-lg', '13px'));
44
+ }
45
+ return this.element;
46
+ }
47
+
48
+ /**
49
+ * Measure the width of text
50
+ * @param {string} text - Text to measure
51
+ * @param {string} fontSize - Font size (optional)
52
+ * @param {string} fontWeight - Font weight (optional)
53
+ * @param {Object} options - Additional options
54
+ * @param {string} options.letterSpacing - Letter spacing
55
+ * @param {string} options.textTransform - Text transform (e.g., 'uppercase')
56
+ * @param {string} options.fontFamily - Font family override (else the
57
+ * base --font-family; lets callers measure cells whose family a
58
+ * theme retargets via a token, e.g. --table-primary-font)
59
+ * @returns {number} Width in pixels
60
+ */
61
+ measure(text, fontSize = null, fontWeight = null, options = {}) {
62
+ const el = this.init();
63
+ if (fontSize) el.style('font-size', fontSize);
64
+ if (fontWeight) el.style('font-weight', fontWeight);
65
+
66
+ // Reset optional styles
67
+ el.style('font-family', options.fontFamily || getCSSVar('--font-family', 'sans-serif'));
68
+ el.style('letter-spacing', options.letterSpacing || 'normal');
69
+ el.style('text-transform', options.textTransform || 'none');
70
+
71
+ el.text(text);
72
+ return el.node().getBoundingClientRect().width;
73
+ }
74
+
75
+ /**
76
+ * Clean up the measurement element
77
+ */
78
+ destroy() {
79
+ if (this.element) {
80
+ this.element.remove();
81
+ this.element = null;
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Create a D3 sequential color scale
88
+ * @param {string} type - Scale type name (orange, blue, green, etc.)
89
+ * @param {Array} domain - [min, max] domain values
90
+ * @returns {Function} D3 color scale function
91
+ */
92
+ export function createColorScale(type, domain) {
93
+ // Endpoints/stops are design tokens (themeable via --scale-* in
94
+ // tokens.css); the hex literals are fallbacks for token-less embeds.
95
+ const t = (name, fallback) => getCSSVar(name, fallback);
96
+ const scales = {
97
+ orange: d3.scaleSequential()
98
+ .domain(domain)
99
+ .interpolator(d3.interpolateRgb(t('--scale-orange-lo', '#fff5eb'), t('--scale-orange-hi', '#ff6b35'))),
100
+
101
+ orangeRed: d3.scaleSequential()
102
+ .domain(domain)
103
+ .interpolator(d3.interpolateRgb(t('--scale-orange-lo', '#fff5eb'), t('--scale-orange-red-hi', '#d7191c'))),
104
+
105
+ blue: d3.scaleSequential()
106
+ .domain(domain)
107
+ .interpolator(d3.interpolateRgb(t('--scale-blue-lo', '#e0f3f8'), t('--scale-blue-hi', '#4575b4'))),
108
+
109
+ teal: d3.scaleSequential()
110
+ .domain(domain)
111
+ .interpolator(d3.interpolateRgb(t('--scale-blue-lo', '#e0f3f8'), t('--scale-teal-hi', '#1a9988'))),
112
+
113
+ aqua: d3.scaleSequential()
114
+ .domain(domain)
115
+ .interpolator(d3.interpolateRgbBasis([
116
+ t('--scale-aqua-1', '#a4d7e1'), t('--scale-aqua-2', '#4ec1d4'),
117
+ t('--scale-aqua-3', '#0095b7'), t('--scale-aqua-4', '#005e8b')])),
118
+
119
+ grayscale: d3.scaleSequential()
120
+ .domain(domain)
121
+ .interpolator(d3.interpolateRgb(t('--scale-gray-lo', '#f5f5f5'), t('--scale-gray-hi', '#333333'))),
122
+
123
+ purple: d3.scaleSequential()
124
+ .domain(domain)
125
+ .interpolator(d3.interpolateRgbBasis([
126
+ t('--scale-purple-1', '#f3e5f5'), t('--scale-purple-2', '#ce93d8'),
127
+ t('--scale-purple-3', '#ab47bc'), t('--scale-purple-4', '#7b1fa2')])),
128
+
129
+ green: d3.scaleSequential()
130
+ .domain(domain)
131
+ .interpolator(d3.interpolateRgbBasis([
132
+ t('--scale-green-1', '#e8f5e9'), t('--scale-green-2', '#a5d6a7'),
133
+ t('--scale-green-3', '#66bb6a'), t('--scale-green-4', '#2e7d32')])),
134
+
135
+ diverging: d3.scaleDiverging()
136
+ .domain([domain[0], (domain[0] + domain[1]) / 2, domain[1]])
137
+ .interpolator(d3.interpolateRdBu)
138
+ };
139
+
140
+ return scales[type] || scales.orange;
141
+ }
142
+
143
+ /**
144
+ * Shared canvas-based text measurer. Returns a `measure(text, weight?, size?,
145
+ * family?)` function bound to one reused 2d context. This is THE text
146
+ * measurement utility — builders must not create their own canvases.
147
+ */
148
+ let _measureCtx = null;
149
+ export function createTextMeasurer(defaults = {}) {
150
+ if (!_measureCtx) {
151
+ _measureCtx = document.createElement('canvas').getContext('2d');
152
+ }
153
+ const baseFamily = defaults.family || getCSSVar('--font-family', 'system-ui, sans-serif');
154
+ const baseSize = defaults.size || getCSSVar('--font-size-md', '13px');
155
+ const baseWeight = defaults.weight || 'normal';
156
+ return function measure(text, weight = baseWeight, size = baseSize, family = baseFamily) {
157
+ _measureCtx.font = `${weight} ${size} ${family}`;
158
+ return _measureCtx.measureText(String(text)).width;
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Calculate luminance for contrast detection
164
+ * @param {string} color - CSS color string
165
+ * @returns {number} Luminance value (0-1)
166
+ */
167
+ export function getLuminance(color) {
168
+ const rgb = d3.rgb(color);
169
+ const r = rgb.r / 255;
170
+ const g = rgb.g / 255;
171
+ const b = rgb.b / 255;
172
+ const [rs, gs, bs] = [r, g, b].map(val =>
173
+ val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4)
174
+ );
175
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
176
+ }
177
+
178
+ /**
179
+ * Get contrasting text color (black or white) based on background
180
+ * @param {string} bgColor - Background color
181
+ * @returns {string} '#000' or '#fff'
182
+ */
183
+ export function getContrastColor(bgColor) {
184
+ return getLuminance(bgColor) > 0.5 ? '#000' : '#fff';
185
+ }
186
+
187
+ /**
188
+ * Format a numeric value based on format type
189
+ * @param {*} value - Value to format
190
+ * @param {string} format - Format type (decimal, decimal2, percentage, currency, integer)
191
+ * @returns {string} Formatted value
192
+ */
193
+ export function formatValue(value, format) {
194
+ if (value === null || value === undefined) return '';
195
+
196
+ switch (format) {
197
+ case 'decimal':
198
+ const num = parseFloat(value);
199
+ return isNaN(num) ? '0.0' : num.toFixed(1);
200
+ case 'decimal2':
201
+ const num2 = parseFloat(value);
202
+ return isNaN(num2) ? '0.00' : num2.toFixed(2);
203
+ case 'percentage':
204
+ return `${value}%`;
205
+ case 'currency':
206
+ return `$${parseFloat(value).toLocaleString()}`;
207
+ case 'integer':
208
+ return parseInt(value).toLocaleString();
209
+ default:
210
+ return value;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Apply staggered entrance animation to a D3 selection
216
+ * @param {d3.Selection} selection - D3 selection to animate
217
+ * @param {number} duration - Animation duration in ms
218
+ * @param {number} staggerDelay - Delay between each element in ms
219
+ * @param {Object} fromStyles - Starting styles
220
+ * @param {Object} toStyles - Ending styles
221
+ */
222
+ export function staggeredEntrance(selection, duration, staggerDelay, fromStyles, toStyles) {
223
+ // Apply initial styles
224
+ Object.entries(fromStyles).forEach(([key, value]) => {
225
+ selection.style(key, value);
226
+ });
227
+
228
+ // Animate to final styles
229
+ selection.transition()
230
+ .delay((d, i) => i * staggerDelay)
231
+ .duration(duration)
232
+ .ease(d3.easeCubicOut)
233
+ .call(transition => {
234
+ Object.entries(toStyles).forEach(([key, value]) => {
235
+ transition.style(key, value);
236
+ });
237
+ });
238
+ }
239
+
240
+ // Default export with all utilities
241
+ export default {
242
+ getCSSVar,
243
+ TextMeasurer,
244
+ createColorScale,
245
+ getLuminance,
246
+ getContrastColor,
247
+ formatValue,
248
+ staggeredEntrance
249
+ };