@inglorious/charts 1.0.1

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.
Files changed (49) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +554 -0
  3. package/package.json +64 -0
  4. package/src/base.css +86 -0
  5. package/src/cartesian/area.js +392 -0
  6. package/src/cartesian/area.test.js +366 -0
  7. package/src/cartesian/bar.js +445 -0
  8. package/src/cartesian/bar.test.js +346 -0
  9. package/src/cartesian/line.js +823 -0
  10. package/src/cartesian/line.test.js +177 -0
  11. package/src/chart.test.js +444 -0
  12. package/src/component/brush.js +264 -0
  13. package/src/component/empty-state.js +33 -0
  14. package/src/component/empty-state.test.js +81 -0
  15. package/src/component/grid.js +123 -0
  16. package/src/component/grid.test.js +123 -0
  17. package/src/component/legend.js +76 -0
  18. package/src/component/legend.test.js +103 -0
  19. package/src/component/tooltip.js +65 -0
  20. package/src/component/tooltip.test.js +96 -0
  21. package/src/component/x-axis.js +212 -0
  22. package/src/component/x-axis.test.js +148 -0
  23. package/src/component/y-axis.js +77 -0
  24. package/src/component/y-axis.test.js +107 -0
  25. package/src/handlers.js +150 -0
  26. package/src/index.js +264 -0
  27. package/src/polar/donut.js +181 -0
  28. package/src/polar/donut.test.js +152 -0
  29. package/src/polar/pie.js +758 -0
  30. package/src/polar/pie.test.js +268 -0
  31. package/src/shape/curve.js +55 -0
  32. package/src/shape/dot.js +104 -0
  33. package/src/shape/rectangle.js +46 -0
  34. package/src/shape/sector.js +58 -0
  35. package/src/template.js +25 -0
  36. package/src/theme.css +90 -0
  37. package/src/utils/cartesian-layout.js +164 -0
  38. package/src/utils/chart-utils.js +30 -0
  39. package/src/utils/colors.js +77 -0
  40. package/src/utils/data-utils.js +155 -0
  41. package/src/utils/data-utils.test.js +210 -0
  42. package/src/utils/extract-data-keys.js +22 -0
  43. package/src/utils/padding.js +16 -0
  44. package/src/utils/paths.js +279 -0
  45. package/src/utils/process-declarative-child.js +46 -0
  46. package/src/utils/scales.js +250 -0
  47. package/src/utils/shared-context.js +166 -0
  48. package/src/utils/shared-context.test.js +237 -0
  49. package/src/utils/tooltip-handlers.js +129 -0
@@ -0,0 +1,445 @@
1
+ /* eslint-disable no-magic-numbers */
2
+ import { html, svg } from "@inglorious/web"
3
+ import { extent } from "d3-array"
4
+ import { scaleBand } from "d3-scale"
5
+
6
+ import { createBrushComponent } from "../component/brush.js"
7
+ import { renderGrid } from "../component/grid.js"
8
+ import { createTooltipComponent, renderTooltip } from "../component/tooltip.js"
9
+ import { renderXAxis } from "../component/x-axis.js"
10
+ import { renderYAxis } from "../component/y-axis.js"
11
+ import { renderRectangle } from "../shape/rectangle.js"
12
+ import { renderCartesianLayout } from "../utils/cartesian-layout.js"
13
+ import { calculatePadding } from "../utils/padding.js"
14
+ import { processDeclarativeChild } from "../utils/process-declarative-child.js"
15
+ import { createCartesianContext } from "../utils/scales.js"
16
+ import { createTooltipHandlers } from "../utils/tooltip-handlers.js"
17
+
18
+ export const bar = {
19
+ /**
20
+ * Config-based rendering entry point.
21
+ * Builds default composition children from entity options and delegates to
22
+ * `renderBarChart`.
23
+ * @param {import('../types/charts').ChartEntity} entity
24
+ * @param {import('@inglorious/web').Api} api
25
+ * @returns {import('lit-html').TemplateResult}
26
+ */
27
+ render(entity, api) {
28
+ const type = api.getType(entity.type)
29
+ const children = [
30
+ entity.showGrid !== false
31
+ ? type.renderCartesianGrid(entity, {}, api)
32
+ : null,
33
+ type.renderXAxis(entity, {}, api),
34
+ type.renderYAxis(entity, {}, api),
35
+ type.renderBar(
36
+ entity,
37
+ { config: { dataKey: "value", multiColor: false } },
38
+ api,
39
+ ),
40
+ ].filter(Boolean)
41
+
42
+ const chartContent = type.renderBarChart(
43
+ entity,
44
+ {
45
+ children,
46
+ config: {
47
+ width: entity.width,
48
+ height: entity.height,
49
+ isRawSVG: true,
50
+ },
51
+ },
52
+ api,
53
+ )
54
+
55
+ return renderCartesianLayout(
56
+ entity,
57
+ {
58
+ chartType: "bar",
59
+ chartContent,
60
+ showLegend: false,
61
+ },
62
+ api,
63
+ )
64
+ },
65
+
66
+ /**
67
+ * Composition rendering entry point for bar charts.
68
+ * @param {import('../types/charts').ChartEntity} entity
69
+ * @param {{ children: any[]|any, config?: Record<string, any> }} params
70
+ * @param {import('@inglorious/web').Api} api
71
+ * @returns {import('lit-html').TemplateResult}
72
+ */
73
+ renderBarChart(entity, { children, config = {} }, api) {
74
+ if (!entity) return html`<div>Entity not found</div>`
75
+ if (!entity.data || !Array.isArray(entity.data)) {
76
+ return html`<div>Entity data is missing or invalid</div>`
77
+ }
78
+
79
+ const width = config.width || entity.width || 800
80
+ const height = config.height || entity.height || 400
81
+ const padding = calculatePadding(width, height)
82
+
83
+ const childrenArray = (
84
+ Array.isArray(children) ? children : [children]
85
+ ).filter(Boolean)
86
+
87
+ const processedChildrenArray = childrenArray
88
+ .map((child) => processDeclarativeChild(child, entity, "bar", api))
89
+ .filter(Boolean)
90
+
91
+ // Separate components using stable flags (survives minification)
92
+ // This ensures correct Z-index ordering: Grid -> Bars -> Axes
93
+ const grid = []
94
+ const axes = []
95
+ const bars = []
96
+ const tooltip = []
97
+ const others = []
98
+
99
+ for (const child of processedChildrenArray) {
100
+ // Use stable flags instead of string matching (survives minification)
101
+ if (typeof child === "function") {
102
+ // If it's already marked, add to the correct bucket
103
+ if (child.isGrid) {
104
+ grid.push(child)
105
+ } else if (child.isAxis) {
106
+ axes.push(child)
107
+ } else if (child.isBar) {
108
+ bars.push(child)
109
+ } else if (child.isTooltip) {
110
+ tooltip.push(child)
111
+ } else {
112
+ // It's a lazy function from index.js - we'll identify its type during processing
113
+ // For now, add to others - it will be processed correctly in the final loop
114
+ others.push(child)
115
+ }
116
+ } else {
117
+ others.push(child)
118
+ }
119
+ }
120
+
121
+ // Store barComponents for Y-axis calculation
122
+ const barComponents = bars
123
+
124
+ // 2. FUNDAMENTAL SCALE - Crucial for alignment
125
+ const categories = entity.data.map(
126
+ (d) => d.label || d.name || d.category || "",
127
+ )
128
+ const xScale = scaleBand()
129
+ .domain(categories)
130
+ .range([padding.left, width - padding.right])
131
+ .padding(0.1)
132
+
133
+ const context = createCartesianContext(
134
+ { ...entity, width, height, padding },
135
+ "bar",
136
+ )
137
+ context.xScale = xScale
138
+ context.dimensions = { width, height, padding }
139
+ context.chartType = "bar" // Include chartType for lazy components
140
+
141
+ // 3. Identify data keys for Y-axis
142
+ const dataKeys =
143
+ config.dataKeys || barComponents.map((c) => c.dataKey || "value")
144
+
145
+ const allValues = entity.data.flatMap((d) =>
146
+ dataKeys.map((k) => d[k]).filter((v) => typeof v === "number"),
147
+ )
148
+ if (allValues.length > 0) {
149
+ const [minVal, maxVal] = extent(allValues)
150
+ context.yScale.domain([Math.min(0, minVal), maxVal]).nice()
151
+ }
152
+
153
+ // 4. Process children from 'others' to identify their real types (lazy functions from index.js)
154
+ // This ensures grid/axes from index.js are placed in the correct buckets
155
+ const identifiedGrid = []
156
+ const identifiedAxes = []
157
+ const remainingOthers = []
158
+
159
+ for (const child of others) {
160
+ if (typeof child === "function") {
161
+ try {
162
+ const result = child(context)
163
+ if (typeof result === "function") {
164
+ if (result.isGrid) {
165
+ identifiedGrid.push(child) // Keep the original lazy function
166
+ } else if (result.isAxis) {
167
+ identifiedAxes.push(child)
168
+ } else {
169
+ remainingOthers.push(child)
170
+ }
171
+ } else {
172
+ remainingOthers.push(child)
173
+ }
174
+ } catch {
175
+ remainingOthers.push(child)
176
+ }
177
+ } else {
178
+ remainingOthers.push(child)
179
+ }
180
+ }
181
+
182
+ // Reorder children for correct Z-index: Grid -> Bars -> Axes -> Tooltip -> Others
183
+ // This ensures grid is behind, bars are in the middle, and axes are on top
184
+ const childrenToProcess = [
185
+ ...grid,
186
+ ...identifiedGrid, // Grids identified from others
187
+ ...bars,
188
+ ...axes,
189
+ ...identifiedAxes, // Axes identified from others
190
+ ...tooltip,
191
+ ...remainingOthers,
192
+ ]
193
+
194
+ // Process children to handle lazy functions (like renderCartesianGrid from index.js)
195
+ // Flow:
196
+ // 1. renderCartesianGrid/renderXAxis from index.js return (ctx) => { return chartType.renderCartesianGrid(...) }
197
+ // 2. chartType.renderCartesianGrid (from bar.js) returns gridFn which is (ctx) => { return svg... }
198
+ // 3. So we need: child(context) -> gridFn, then gridFn(context) -> svg
199
+ // Simplified deterministic approach: all functions from index.js return (ctx) => ..., so we can safely call with context
200
+ const processedChildren = childrenToProcess.map((child) => {
201
+ // Non-function children are passed through as-is
202
+ if (typeof child !== "function") {
203
+ return child
204
+ }
205
+
206
+ // If it's a marked component (isGrid, isBar, isAxis, etc), it expects context directly
207
+ if (child.isGrid || child.isAxis || child.isBar || child.isTooltip) {
208
+ // For bars, also pass barIndex and totalBars
209
+ if (child.isBar) {
210
+ const barIndex = barComponents.indexOf(child)
211
+ return child(context, barIndex, barComponents.length)
212
+ }
213
+ return child(context)
214
+ }
215
+
216
+ // If it's a function from index.js (renderCartesianGrid, etc),
217
+ // it returns another function that also expects context
218
+ const result = child(context)
219
+ // If the result is a function (marked component), call it with context
220
+ if (typeof result === "function") {
221
+ // For bars, also pass barIndex and totalBars
222
+ if (result.isBar) {
223
+ const barIndex = barComponents.indexOf(result)
224
+ return result(context, barIndex, barComponents.length)
225
+ }
226
+ return result(context)
227
+ }
228
+ // Otherwise, return the result directly (already SVG or TemplateResult)
229
+ return result
230
+ })
231
+
232
+ const svgContent = svg`
233
+ <svg width=${width} height=${height} viewBox="0 0 ${width} ${height}">
234
+ ${processedChildren}
235
+ </svg>
236
+ `
237
+
238
+ if (config.isRawSVG) return svgContent
239
+
240
+ return html`
241
+ <div
242
+ class="iw-chart"
243
+ style="display: block; position: relative; width: 100%; box-sizing: border-box;"
244
+ >
245
+ ${svgContent} ${renderTooltip(entity, {}, api)}
246
+ </div>
247
+ `
248
+ },
249
+
250
+ /**
251
+ * Composition sub-render for bars.
252
+ * @param {import('../types/charts').ChartEntity} entity
253
+ * @param {{ config?: Record<string, any> }} params
254
+ * @param {import('@inglorious/web').Api} api
255
+ * @returns {(ctx: Record<string, any>, barIndex: number, totalBars: number) => import('lit-html').TemplateResult}
256
+ */
257
+ renderBar(entity, { config = {} }, api) {
258
+ // Preserve config values in closure
259
+ const { dataKey = "value", fill, multiColor = false } = config
260
+ const drawFn = (ctx, barIndex, totalBars) => {
261
+ const entityFromContext = ctx.entity || entity
262
+ if (!entityFromContext) return svg``
263
+ const entityColors = entityFromContext.colors || [
264
+ "#8884d8",
265
+ "#82ca9d",
266
+ "#ffc658",
267
+ "#ff7300",
268
+ ]
269
+ const { xScale, yScale, dimensions } = ctx
270
+
271
+ // When there's only one bar, center it in the band without using subScale
272
+ let barWidth, xOffset
273
+ if (totalBars === 1) {
274
+ // Single bar: occupies 80% of the band and is centered
275
+ const bandwidth = xScale.bandwidth()
276
+ barWidth = bandwidth * 0.8
277
+ xOffset = (bandwidth - barWidth) / 2 // Center
278
+ } else {
279
+ // Multiple bars: use subScale to group them
280
+ const subScale = scaleBand()
281
+ .domain(Array.from({ length: totalBars }, (_, i) => i))
282
+ .range([0, xScale.bandwidth()])
283
+ .padding(0.1)
284
+ barWidth = subScale.bandwidth()
285
+ xOffset = subScale(barIndex)
286
+ }
287
+
288
+ return svg`
289
+ ${entityFromContext.data.map((d, i) => {
290
+ const category = d.label || d.name || d.category || String(i)
291
+ const value = d[dataKey] ?? 0
292
+ const bandStart = xScale(category)
293
+
294
+ // Skip if bandStart is undefined or NaN (invalid category)
295
+ if (bandStart == null || isNaN(bandStart)) {
296
+ return svg``
297
+ }
298
+
299
+ const x = bandStart + xOffset
300
+ const y = yScale(value)
301
+
302
+ // Calculate bar height: distance from top (y) to bottom of chart area
303
+ const chartBottom = dimensions.height - dimensions.padding.bottom
304
+ const barHeight = Math.max(0, chartBottom - y)
305
+
306
+ // Skip if bar has no height or invalid dimensions
307
+ if (barHeight <= 0 || isNaN(barHeight) || isNaN(x) || isNaN(y)) {
308
+ return svg``
309
+ }
310
+
311
+ const color = multiColor
312
+ ? d.color || entityColors[i % entityColors.length]
313
+ : fill || d.color || entityColors[barIndex % entityColors.length]
314
+
315
+ const { onMouseEnter, onMouseLeave } = createTooltipHandlers({
316
+ entity: entityFromContext,
317
+ api,
318
+ tooltipData: { label: category, value, color },
319
+ })
320
+ return renderRectangle({
321
+ x,
322
+ y,
323
+ width: barWidth,
324
+ height: barHeight,
325
+ fill: color,
326
+ onMouseEnter,
327
+ onMouseLeave,
328
+ })
329
+ })}
330
+ `
331
+ }
332
+
333
+ drawFn.isBar = true
334
+ drawFn.dataKey = config.dataKey || "value"
335
+ return drawFn
336
+ },
337
+
338
+ /**
339
+ * Composition sub-render for X axis.
340
+ * @param {import('../types/charts').ChartEntity} entity
341
+ * @param {{ config?: Record<string, any> }} params
342
+ * @param {import('@inglorious/web').Api} api
343
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
344
+ */
345
+ renderXAxis(entity, { config = {} }, api) {
346
+ // Return a function that preserves the original object
347
+ // This prevents lit-html from evaluating the function before passing it
348
+ const renderFn = (ctx) => {
349
+ const entityFromContext = ctx.entity || entity
350
+ if (!entityFromContext) return svg``
351
+ return renderXAxis(
352
+ entityFromContext,
353
+ {
354
+ xScale: ctx.xScale,
355
+ yScale: ctx.yScale,
356
+ padding: ctx.dimensions.padding,
357
+ width: ctx.dimensions.width,
358
+ height: ctx.dimensions.height,
359
+ },
360
+ api,
361
+ )
362
+ }
363
+ // Mark as axis component for stable identification (consistent with area.js)
364
+ renderFn.isAxis = true
365
+ renderFn.config = config
366
+ renderFn.api = api
367
+ // Add a special property to prevent lit-html from rendering directly
368
+ // This makes the function be treated as a lit-html "directive"
369
+ Object.defineProperty(renderFn, "_$litType$", {
370
+ value: undefined,
371
+ writable: false,
372
+ enumerable: false,
373
+ configurable: false,
374
+ })
375
+ // Return the marked function
376
+ return renderFn
377
+ },
378
+
379
+ /**
380
+ * Composition sub-render for Y axis.
381
+ * @param {import('../types/charts').ChartEntity} entity
382
+ * @param {{ config?: Record<string, any> }} params
383
+ * @param {import('@inglorious/web').Api} api
384
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
385
+ */
386
+ renderYAxis(entity, props, api) {
387
+ const axisFn = (ctx) => {
388
+ const entityFromContext = ctx.entity || entity
389
+ return renderYAxis(
390
+ entityFromContext,
391
+ {
392
+ yScale: ctx.yScale,
393
+ ...ctx.dimensions,
394
+ customTicks: ctx.yScale.ticks
395
+ ? ctx.yScale.ticks(5)
396
+ : ctx.yScale.domain(),
397
+ },
398
+ api,
399
+ )
400
+ }
401
+ // Mark as axis component for stable identification (consistent with area.js)
402
+ axisFn.isAxis = true
403
+ return axisFn
404
+ },
405
+
406
+ /**
407
+ * Composition sub-render for cartesian grid.
408
+ * @param {import('../types/charts').ChartEntity} entity
409
+ * @param {{ config?: Record<string, any> }} params
410
+ * @param {import('@inglorious/web').Api} api
411
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
412
+ */
413
+ renderCartesianGrid(entity, { config = {} }, api) {
414
+ const gridFn = (ctx) => {
415
+ const entityFromContext = ctx.entity || entity
416
+ if (!entityFromContext) return svg``
417
+ return renderGrid(
418
+ entityFromContext,
419
+ {
420
+ ...ctx.dimensions,
421
+ xScale: ctx.xScale,
422
+ yScale: ctx.yScale,
423
+ stroke: config.stroke || "#eee",
424
+ strokeDasharray: config.strokeDasharray || "5 5",
425
+ },
426
+ api,
427
+ )
428
+ }
429
+ // Mark as grid component for stable identification (consistent with area.js)
430
+ gridFn.isGrid = true
431
+ return gridFn
432
+ },
433
+
434
+ /**
435
+ * Composition sub-render for tooltip overlay.
436
+ * @type {(entity: import('../types/charts').ChartEntity, params: { config?: Record<string, any> }, api: import('@inglorious/web').Api) => (ctx: Record<string, any>) => import('lit-html').TemplateResult}
437
+ */
438
+ renderTooltip: createTooltipComponent(),
439
+
440
+ /**
441
+ * Composition sub-render for brush control.
442
+ * @type {(entity: import('../types/charts').ChartEntity, params: { config?: Record<string, any> }, api: import('@inglorious/web').Api) => (ctx: Record<string, any>) => import('lit-html').TemplateResult}
443
+ */
444
+ renderBrush: createBrushComponent(),
445
+ }