@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,823 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ import { html, repeat, svg } from "@inglorious/web"
4
+
5
+ import { createBrushComponent } from "../component/brush.js"
6
+ import { renderGrid } from "../component/grid.js"
7
+ import { renderLegend } from "../component/legend.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 { chart } from "../index.js"
12
+ import { renderDot } from "../shape/dot.js"
13
+ import {
14
+ getTransformedData,
15
+ isMultiSeries,
16
+ parseDimension,
17
+ } from "../utils/data-utils.js"
18
+ import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js"
19
+ import { calculatePadding } from "../utils/padding.js"
20
+ import { generateLinePath } from "../utils/paths.js"
21
+ import { processDeclarativeChild } from "../utils/process-declarative-child.js"
22
+ import { getFilteredData } from "../utils/scales.js"
23
+ import { createSharedContext } from "../utils/shared-context.js"
24
+ import {
25
+ createTooltipHandlers,
26
+ createTooltipMoveHandler,
27
+ } from "../utils/tooltip-handlers.js"
28
+
29
+ // Removed global Maps - now using local context to avoid interference between charts
30
+
31
+ export const line = {
32
+ /**
33
+ * Config-based rendering entry point.
34
+ * Builds default composition children from entity options and delegates to
35
+ * `renderLineChart`.
36
+ * @param {import('../types/charts').ChartEntity} entity
37
+ * @param {import('@inglorious/web').Api} api
38
+ * @returns {import('lit-html').TemplateResult}
39
+ */
40
+ render(entity, api) {
41
+ const type = api.getType(entity.type)
42
+
43
+ // Apply data filtering if brush is enabled
44
+ const entityData = entity.brush?.enabled
45
+ ? getFilteredData(entity)
46
+ : entity.data
47
+
48
+ // Create entity with filtered data for rendering
49
+ const entityWithFilteredData = { ...entity, data: entityData }
50
+
51
+ // Convert entity config to declarative children
52
+ const children = buildChildrenFromConfig(entityWithFilteredData)
53
+
54
+ // Extract dataKeys for config
55
+ let dataKeys = []
56
+ if (isMultiSeries(entityWithFilteredData.data)) {
57
+ dataKeys = entityWithFilteredData.data.map(
58
+ (series, idx) =>
59
+ series.dataKey || series.name || series.label || `series${idx}`,
60
+ )
61
+ } else {
62
+ dataKeys = ["y", "value"].filter(
63
+ (key) =>
64
+ entityWithFilteredData.data &&
65
+ entityWithFilteredData.data.length > 0 &&
66
+ entityWithFilteredData.data[0][key] !== undefined,
67
+ )
68
+ if (dataKeys.length === 0) {
69
+ dataKeys = ["value"]
70
+ }
71
+ }
72
+
73
+ // Use the unified motor (renderLineChart)
74
+ // Pass original entity in config so brush can access unfiltered data
75
+ return type.renderLineChart(
76
+ entityWithFilteredData,
77
+ {
78
+ children,
79
+ config: {
80
+ width: entityWithFilteredData.width,
81
+ height: entityWithFilteredData.height,
82
+ dataKeys,
83
+ originalEntity: entity, // Pass original entity for brush
84
+ },
85
+ },
86
+ api,
87
+ )
88
+ },
89
+
90
+ /**
91
+ * Composition rendering entry point for line charts.
92
+ * @param {import('../types/charts').ChartEntity} entity
93
+ * @param {{ children: any[]|any, config?: Record<string, any> }} params
94
+ * @param {import('@inglorious/web').Api} api
95
+ * @returns {import('lit-html').TemplateResult}
96
+ */
97
+ renderLineChart(entity, { children, config = {} }, api) {
98
+ if (!entity) {
99
+ return svg`<text>Entity not found</text>`
100
+ }
101
+
102
+ // 1. Define the data
103
+ let entityData = config.data || entity.data
104
+ const entityWithData = { ...entity, data: entityData }
105
+
106
+ // 2. Extract dataKeys from config or auto-extract from children
107
+ const dataKeysSet = new Set()
108
+ if (config.dataKeys && Array.isArray(config.dataKeys)) {
109
+ config.dataKeys.forEach((key) => dataKeysSet.add(key))
110
+ } else if (children) {
111
+ // Auto-extract dataKeys from children
112
+ const autoDataKeys = extractDataKeysFromChildren(children)
113
+ autoDataKeys.forEach((key) => dataKeysSet.add(key))
114
+ }
115
+
116
+ const style = config.style || {}
117
+ const width =
118
+ parseDimension(config.width || style.width) ||
119
+ parseDimension(entity.width) ||
120
+ 800
121
+ let height =
122
+ parseDimension(config.height || style.height) ||
123
+ parseDimension(entity.height) ||
124
+ 400
125
+ const padding = calculatePadding(width, height)
126
+
127
+ const childrenArray = (
128
+ Array.isArray(children) ? children : [children]
129
+ ).filter(Boolean)
130
+
131
+ // Process declarative children before creating the context
132
+ // For brush, use original entity data (if available) to ensure correct positioning
133
+ const entityForBrush = config.originalEntity || entityWithData
134
+ const processedChildrenArray = childrenArray
135
+ .map((child) => {
136
+ // If this is a Brush component, use original entity data
137
+ if (child && typeof child === "object" && child.type === "Brush") {
138
+ return processDeclarativeChild(child, entityForBrush, "line", api)
139
+ }
140
+ return processDeclarativeChild(child, entityWithData, "line", api)
141
+ })
142
+ .filter(Boolean)
143
+
144
+ const chartHeight = height
145
+
146
+ // For X scale creation, use original entity data if available (config mode with brush)
147
+ // Otherwise use entityWithData (composition mode)
148
+ // This ensures X scale always has access to original data with valid x/date values
149
+ const entityForXScale = config.originalEntity || entityWithData
150
+
151
+ const context = createSharedContext(
152
+ entityForXScale,
153
+ {
154
+ width,
155
+ height: chartHeight,
156
+ padding,
157
+ usedDataKeys: dataKeysSet,
158
+ chartType: "line",
159
+ // Pass filtered entity for Y scale calculation (uses filtered data for maxValue)
160
+ filteredEntity: entityWithData,
161
+ },
162
+ api,
163
+ )
164
+
165
+ // Simplified check: processDeclarativeChild now guarantees the flag is set
166
+ const hasBrush = processedChildrenArray.some(
167
+ (child) => child && child.isBrush,
168
+ )
169
+ const brushHeight = hasBrush ? 60 : 0
170
+ const totalHeight = chartHeight + brushHeight
171
+
172
+ const svgStyle = {
173
+ width: style.width || (typeof width === "number" ? `${width}px` : width),
174
+ height:
175
+ style.height ||
176
+ (typeof totalHeight === "number" ? `${totalHeight}px` : totalHeight),
177
+ maxWidth: style.maxWidth,
178
+ maxHeight: style.maxHeight,
179
+ aspectRatio: style.aspectRatio,
180
+ margin: style.margin,
181
+ ...style,
182
+ }
183
+
184
+ // 4. APPLY ZOOM
185
+ if (entity.brush?.enabled && entity.brush.startIndex !== undefined) {
186
+ const { startIndex, endIndex } = entity.brush
187
+ context.xScale.domain([startIndex, endIndex])
188
+ }
189
+
190
+ context.dimensions = { width, height: chartHeight, padding }
191
+ context.totalHeight = totalHeight
192
+ context.entity = entityWithData
193
+ // Use originalEntity from config if available (for brush in config mode)
194
+ // Otherwise use entity (for composition mode)
195
+ context.fullEntity = config.originalEntity || entity
196
+ context.api = api
197
+
198
+ // Context is now passed directly to renderLine, no need for global cache
199
+
200
+ // Separate components using stable flags (survives minification)
201
+ // This ensures correct Z-index ordering: Grid -> Lines -> Axes -> Dots -> Brush
202
+ const grid = []
203
+ const axes = []
204
+ const lines = []
205
+ const dots = []
206
+ const tooltip = []
207
+ const legend = []
208
+ const brush = []
209
+ const others = []
210
+
211
+ // Simplified categorization: processDeclarativeChild now guarantees flags are set
212
+ for (const child of processedChildrenArray) {
213
+ if (typeof child === "function") {
214
+ // Flags MUST exist now thanks to processDeclarativeChild
215
+ if (child.isGrid) grid.push(child)
216
+ else if (child.isAxis) axes.push(child)
217
+ else if (child.isLine) lines.push(child)
218
+ else if (child.isDots) dots.push(child)
219
+ else if (child.isTooltip) tooltip.push(child)
220
+ else if (child.isLegend) legend.push(child)
221
+ else if (child.isBrush) brush.push(child)
222
+ else others.push(child)
223
+ } else {
224
+ others.push(child)
225
+ }
226
+ }
227
+
228
+ // Reorder children for correct Z-index: Grid -> Lines -> Axes -> Dots -> Tooltip -> Legend -> Brush -> Others
229
+ // This ensures grid is behind, lines are in the middle, and axes are on top, brush is at the bottom
230
+ const childrenToProcess = [
231
+ ...grid,
232
+ ...lines,
233
+ ...axes,
234
+ ...dots,
235
+ ...tooltip,
236
+ ...legend,
237
+ ...brush,
238
+ ...others,
239
+ ]
240
+
241
+ // Build style string
242
+ const styleString = Object.entries(svgStyle)
243
+ .filter(([, value]) => value != null)
244
+ .map(([key, value]) => {
245
+ // Convert camelCase to kebab-case
246
+ const kebabKey = key.replace(/([A-Z])/g, "-$1").toLowerCase()
247
+ return `${kebabKey}: ${value}`
248
+ })
249
+ .join("; ")
250
+
251
+ // Process children to handle lazy functions (like renderDots from index.js)
252
+ // Flow:
253
+ // 1. renderCartesianGrid/renderXAxis from index.js return (ctx) => { return chartType.renderCartesianGrid(...) }
254
+ // 2. chartType.renderCartesianGrid (from line.js) returns gridFn which is (ctx) => { return svg... }
255
+ // 3. So we need: child(context) -> gridFn, then gridFn(context) -> svg
256
+ // Simplified deterministic approach: all functions from index.js return (ctx) => ..., so we can safely call with context
257
+ const processedChildren = childrenToProcess.map((child) => {
258
+ // Non-function children are passed through as-is
259
+ if (typeof child !== "function") {
260
+ return child
261
+ }
262
+
263
+ // If it's a marked component (isGrid, isLine, etc), it expects context directly
264
+ if (
265
+ child.isGrid ||
266
+ child.isAxis ||
267
+ child.isLine ||
268
+ child.isDots ||
269
+ child.isTooltip ||
270
+ child.isLegend ||
271
+ child.isBrush
272
+ ) {
273
+ return child(context)
274
+ }
275
+
276
+ // If it's a function from index.js (renderCartesianGrid, etc),
277
+ // it returns another function that also expects context
278
+ const result = child(context)
279
+ // If the result is a function (marked component), call it with context
280
+ if (typeof result === "function") {
281
+ return result(context)
282
+ }
283
+ // Otherwise, return the result directly (already SVG or TemplateResult)
284
+ return result
285
+ })
286
+
287
+ const svgContent = svg`
288
+ <svg
289
+ width=${width}
290
+ height=${totalHeight}
291
+ viewBox="0 0 ${width} ${totalHeight}"
292
+ class="iw-chart-svg"
293
+ data-entity-id=${entity.id}
294
+ style=${styleString || undefined}
295
+ @mousemove=${createTooltipMoveHandler({ entity: entityWithData, api })}
296
+ >
297
+ <defs>
298
+ <clipPath id="chart-clip-${entity.id}">
299
+ <rect
300
+ x=${padding.left}
301
+ y=${padding.top}
302
+ width=${width - padding.left - padding.right}
303
+ height=${height - padding.top - padding.bottom}
304
+ />
305
+ </clipPath>
306
+ </defs>
307
+ ${processedChildren}
308
+ </svg>
309
+ `
310
+
311
+ const tooltipResult = renderTooltip(entityWithData, {}, api)
312
+
313
+ const finalResult = html`
314
+ <div
315
+ class="iw-chart"
316
+ style="display: block; position: relative; width: 100%; box-sizing: border-box;"
317
+ >
318
+ ${svgContent} ${tooltipResult}
319
+ </div>
320
+ `
321
+ return finalResult
322
+ },
323
+
324
+ // Helper functions moved to utils/data-utils.js as pure functions
325
+
326
+ /**
327
+ * Composition sub-render for cartesian grid.
328
+ * @param {import('../types/charts').ChartEntity} entity
329
+ * @param {{ config?: Record<string, any> }} params
330
+ * @param {import('@inglorious/web').Api} api
331
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
332
+ */
333
+ renderCartesianGrid(entity, { config = {} }, api) {
334
+ const gridFn = (ctx) => {
335
+ const { xScale, yScale, dimensions } = ctx
336
+ const entityFromContext = ctx.entity || entity
337
+ const { stroke = "#eee", strokeDasharray = "5 5" } = config
338
+ // For grid, we still need data with indices for X scale
339
+ const transformedData = entityFromContext.data.map((d, i) => ({
340
+ x: i,
341
+ y: 0,
342
+ }))
343
+ // Use same ticks as renderYAxis for consistency (Recharts approach)
344
+ // Recharts uses tickCount: 5 by default, which calls scale.ticks(5)
345
+ const ticks = yScale.ticks ? yScale.ticks(5) : yScale.domain()
346
+ return renderGrid(
347
+ { ...entityFromContext, data: transformedData },
348
+ {
349
+ xScale,
350
+ yScale,
351
+ customYTicks: ticks,
352
+ ...dimensions,
353
+ stroke,
354
+ strokeDasharray,
355
+ },
356
+ api,
357
+ )
358
+ }
359
+ // Mark as grid component for stable identification
360
+ gridFn.isGrid = true
361
+ return gridFn
362
+ },
363
+
364
+ /**
365
+ * Composition sub-render for X axis.
366
+ * @param {import('../types/charts').ChartEntity} entity
367
+ * @param {{ config?: Record<string, any> }} params
368
+ * @param {import('@inglorious/web').Api} api
369
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
370
+ */
371
+ renderXAxis(entity, { config = {} }, api) {
372
+ const axisFn = (ctx) => {
373
+ const { xScale, yScale, dimensions } = ctx
374
+
375
+ const entityFromContext = ctx.entity || entity
376
+
377
+ const { dataKey } = config
378
+
379
+ const labels = entityFromContext.data.map(
380
+ (d, i) => d[dataKey] || d.name || d.x || d.date || String(i),
381
+ )
382
+
383
+ const axisScale = (val) => xScale(val)
384
+
385
+ axisScale.bandwidth = () => 0.0001
386
+ axisScale.domain = () => xScale.domain() // Use the [start, end] domain from context
387
+ axisScale.range = () => xScale.range()
388
+ axisScale.copy = () => axisScale
389
+
390
+ return renderXAxis(
391
+ {
392
+ ...entityFromContext,
393
+ data: entityFromContext.data.map((_, i) => ({ x: i, y: 0 })),
394
+ xLabels: labels,
395
+ },
396
+
397
+ {
398
+ xScale: axisScale,
399
+ yScale,
400
+ ...dimensions,
401
+ },
402
+
403
+ api,
404
+ )
405
+ }
406
+
407
+ axisFn.isAxis = true
408
+
409
+ return axisFn
410
+ },
411
+
412
+ /**
413
+ * Composition sub-render for Y axis.
414
+ * @param {import('../types/charts').ChartEntity} entity
415
+ * @param {{ config?: Record<string, any> }} params
416
+ * @param {import('@inglorious/web').Api} api
417
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
418
+ */
419
+ renderYAxis(entity, { config = {} }, api) {
420
+ // eslint-disable-next-line no-unused-vars
421
+ const _config = config
422
+ const axisFn = (ctx) => {
423
+ const { yScale, dimensions } = ctx
424
+ const entityFromContext = ctx.entity || entity
425
+ // Use d3-scale's ticks() method like Recharts does
426
+ // Recharts uses tickCount: 5 by default, which calls scale.ticks(5)
427
+ // The d3-scale algorithm automatically chooses "nice" intervals
428
+ const ticks = yScale.ticks ? yScale.ticks(5) : yScale.domain()
429
+ return renderYAxis(
430
+ entityFromContext,
431
+ {
432
+ yScale,
433
+ customTicks: ticks,
434
+ ...dimensions,
435
+ },
436
+ api,
437
+ )
438
+ }
439
+ // Mark as axis component for stable identification
440
+ axisFn.isAxis = true
441
+ return axisFn
442
+ },
443
+
444
+ /**
445
+ * Composition sub-render for line paths.
446
+ * @param {import('../types/charts').ChartEntity} entity
447
+ * @param {{ config?: Record<string, any> }} params
448
+ * @param {import('@inglorious/web').Api} api
449
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
450
+ */
451
+ renderLine(entity, { config = {} }, api) {
452
+ const lineFn = (ctx) => {
453
+ const entityFromContext = ctx.entity || entity
454
+ if (!entityFromContext || !entityFromContext.data) return svg``
455
+
456
+ // Use context from parent (renderLineChart)
457
+ const {
458
+ dataKey,
459
+ stroke = "#8884d8",
460
+ type: curveType = "linear",
461
+ showDots = false,
462
+ dotFill,
463
+ dotR = "0.25em",
464
+ dotStroke = "white",
465
+ dotStrokeWidth = "0.125em",
466
+ } = config
467
+
468
+ // Extract data based on dataKey for this line
469
+ const data = getTransformedData(entityFromContext, dataKey)
470
+ if (!data || data.length === 0) return svg``
471
+
472
+ const { xScale, yScale } = ctx
473
+
474
+ // CRITICAL: Preserve original index for filtered data
475
+ // When brush is enabled, entity.data is filtered (e.g., indices 31-99)
476
+ // But getTransformedData creates data with x: 0, 1, 2... (reset indices)
477
+ // We need to map these back to their original positions in the full dataset
478
+ // This ensures the line is drawn at the correct X position matching the xScale domain
479
+ const startIndex = entityFromContext.brush?.startIndex || 0
480
+ const chartData = data.map((d, i) => ({
481
+ ...d,
482
+ x: startIndex + i, // The 'x' must be the real position in the total series
483
+ }))
484
+
485
+ // Generate path using the corrected data with original indices
486
+ const path = generateLinePath(chartData, xScale, yScale, curveType)
487
+
488
+ // If path is null or empty, return empty
489
+ if (!path || path.includes("NaN")) {
490
+ return svg``
491
+ }
492
+
493
+ // Render dots if showDots is true
494
+ // Use chartData (with corrected indices) instead of data
495
+ const dots = showDots
496
+ ? repeat(
497
+ chartData,
498
+ (d, i) => `${dataKey}-${i}`,
499
+ (d, i) => {
500
+ const x = xScale(d.x)
501
+ const y = yScale(d.y)
502
+ // Use the X-axis label (point index) as label, like config mode
503
+ // Get the original data point to access the name/label from X-axis
504
+ const originalDataPoint = entityFromContext.data[i]
505
+ const xAxisLabel =
506
+ originalDataPoint?.name ||
507
+ originalDataPoint?.label ||
508
+ String(d.x)
509
+ const dotLabel = xAxisLabel // Use X-axis point as label (consistent with config mode)
510
+ const dotValue = d.y
511
+
512
+ const {
513
+ onMouseEnter: dotOnMouseEnter,
514
+ onMouseLeave: dotOnMouseLeave,
515
+ } = createTooltipHandlers({
516
+ entity: entityFromContext,
517
+ api: ctx.api || api,
518
+ tooltipData: {
519
+ label: dotLabel,
520
+ value: dotValue,
521
+ color: dotFill || stroke,
522
+ },
523
+ })
524
+
525
+ return renderDot({
526
+ cx: x,
527
+ cy: y,
528
+ r: dotR,
529
+ fill: dotFill || stroke,
530
+ stroke: dotStroke,
531
+ strokeWidth: dotStrokeWidth,
532
+ className: "iw-chart-dot",
533
+ onMouseEnter: dotOnMouseEnter,
534
+ onMouseLeave: dotOnMouseLeave,
535
+ })
536
+ },
537
+ )
538
+ : ""
539
+
540
+ const pathElement = svg`
541
+ <g
542
+ class="iw-chart-line-group"
543
+ data-data-key="${dataKey}"
544
+ clip-path="url(#chart-clip-${entityFromContext.id})"
545
+ >
546
+ <path
547
+ class="iw-chart-line"
548
+ data-entity-id="${entityFromContext.id}"
549
+ data-data-key="${dataKey}"
550
+ d="${path}"
551
+ stroke="${stroke}"
552
+ fill="none"
553
+ stroke-width="2"
554
+ style="stroke: ${stroke} !important;"
555
+ />
556
+ ${dots}
557
+ </g>
558
+ `
559
+
560
+ return pathElement
561
+ }
562
+ // Mark as line component for stable identification
563
+ lineFn.isLine = true
564
+ // Expose dataKey for automatic extraction
565
+ lineFn.dataKey = config.dataKey
566
+ return lineFn
567
+ },
568
+
569
+ /**
570
+ * Composition sub-render for line dots.
571
+ * @param {import('../types/charts').ChartEntity} entity
572
+ * @param {{ config?: Record<string, any> }} params
573
+ * @param {import('@inglorious/web').Api} api
574
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
575
+ */
576
+ renderDots(entity, { config = {} }, api) {
577
+ const dotsFn = (ctx) => {
578
+ const { xScale, yScale } = ctx
579
+ const entityFromContext = ctx.entity || entity
580
+ const {
581
+ dataKey,
582
+ fill = "#8884d8",
583
+ r = "0.25em",
584
+ stroke = "white",
585
+ strokeWidth = "0.125em",
586
+ } = config
587
+
588
+ const data = getTransformedData(entityFromContext, dataKey)
589
+ if (!data || data.length === 0) return svg``
590
+
591
+ // CRITICAL: Preserve original index for filtered data (same as renderLine)
592
+ // When brush is enabled, entity.data is filtered (e.g., indices 31-99)
593
+ // But getTransformedData creates data with x: 0, 1, 2... (reset indices)
594
+ // We need to map these back to their original positions in the full dataset
595
+ const startIndex = entityFromContext.brush?.startIndex || 0
596
+ const chartData = data.map((d, i) => ({
597
+ ...d,
598
+ x: startIndex + i, // The 'x' must be the real position in the total series
599
+ }))
600
+
601
+ return svg`
602
+ <g class="iw-chart-dots" data-data-key=${dataKey} clip-path="url(#chart-clip-${ctx.entity.id})">
603
+ ${repeat(
604
+ chartData,
605
+ (d, i) => `${dataKey}-${i}`,
606
+ (d, i) => {
607
+ const x = xScale(d.x)
608
+ const y = yScale(d.y)
609
+ // Use the X-axis label (point index) as label, like config mode
610
+ // Get the original data point to access the name/label from X-axis
611
+ const originalDataPoint = entityFromContext.data[i]
612
+ const xAxisLabel =
613
+ originalDataPoint?.name ||
614
+ originalDataPoint?.label ||
615
+ String(d.x)
616
+ const label = xAxisLabel // Use X-axis point as label (consistent with config mode)
617
+ const value = d.y
618
+
619
+ const { onMouseEnter, onMouseLeave } = createTooltipHandlers({
620
+ entity: entityFromContext,
621
+ api: ctx.api || api,
622
+ tooltipData: {
623
+ label,
624
+ value,
625
+ color: fill,
626
+ },
627
+ })
628
+
629
+ return renderDot({
630
+ cx: x,
631
+ cy: y,
632
+ r,
633
+ fill,
634
+ stroke,
635
+ strokeWidth,
636
+ className: "iw-chart-dot",
637
+ onMouseEnter,
638
+ onMouseLeave,
639
+ })
640
+ },
641
+ )}
642
+ </g>
643
+ `
644
+ }
645
+ // Mark as dots component for stable identification
646
+ dotsFn.isDots = true
647
+ // Expose dataKey for automatic extraction
648
+ dotsFn.dataKey = config.dataKey
649
+ return dotsFn
650
+ },
651
+
652
+ /**
653
+ * Composition sub-render for legend.
654
+ * @param {import('../types/charts').ChartEntity} entity
655
+ * @param {{ config?: Record<string, any> }} params
656
+ * @param {import('@inglorious/web').Api} api
657
+ * @returns {(ctx: Record<string, any>) => import('lit-html').TemplateResult}
658
+ */
659
+ renderLegend(entity, { config = {} }, api) {
660
+ const legendFn = (ctx) => {
661
+ const { dimensions } = ctx
662
+ const { width, padding } = dimensions
663
+ const { dataKeys, labels, colors } = config
664
+
665
+ // Create series from dataKeys
666
+ const series = (dataKeys || []).map((dataKey, index) => {
667
+ // Use custom label if provided, otherwise format dataKey (e.g., "productA" -> "Product A")
668
+ const label =
669
+ labels?.[index] ||
670
+ dataKey
671
+ .replace(/([A-Z])/g, " $1")
672
+ .replace(/^./, (str) => str.toUpperCase())
673
+ .trim()
674
+
675
+ return {
676
+ name: label,
677
+ color: colors?.[index],
678
+ }
679
+ })
680
+
681
+ // Default colors if not provided
682
+ const defaultColors = [
683
+ "#8884d8",
684
+ "#82ca9d",
685
+ "#ffc658",
686
+ "#ff7300",
687
+ "#0088fe",
688
+ "#00c49f",
689
+ "#ffbb28",
690
+ "#ff8042",
691
+ ]
692
+ const legendColors = colors || defaultColors
693
+
694
+ return renderLegend(
695
+ entity,
696
+ {
697
+ series,
698
+ colors: legendColors,
699
+ width,
700
+ padding,
701
+ },
702
+ api,
703
+ )
704
+ }
705
+ // Mark as legend for identification during processing
706
+ legendFn.isLegend = true
707
+ return legendFn
708
+ },
709
+
710
+ /**
711
+ * Composition sub-render for tooltip overlay.
712
+ * @type {(entity: import('../types/charts').ChartEntity, params: { config?: Record<string, any> }, api: import('@inglorious/web').Api) => (ctx: Record<string, any>) => import('lit-html').TemplateResult}
713
+ */
714
+ renderTooltip: createTooltipComponent(),
715
+
716
+ /**
717
+ * Composition sub-render for brush control.
718
+ * @type {(entity: import('../types/charts').ChartEntity, params: { config?: Record<string, any> }, api: import('@inglorious/web').Api) => (ctx: Record<string, any>) => import('lit-html').TemplateResult}
719
+ */
720
+ renderBrush: createBrushComponent(),
721
+ }
722
+
723
+ /**
724
+ * Builds declarative children from entity config for renderChart (config style)
725
+ * Converts entity configuration into children objects that renderLineChart can process
726
+ */
727
+ function buildChildrenFromConfig(entity) {
728
+ const children = []
729
+
730
+ // Grid
731
+ if (entity.showGrid !== false) {
732
+ children.push(
733
+ chart.CartesianGrid({ stroke: "#eee", strokeDasharray: "5 5" }),
734
+ )
735
+ }
736
+
737
+ // XAxis - determine dataKey from entity or data structure
738
+ let xAxisDataKey = entity.dataKey
739
+ if (!xAxisDataKey && entity.data && entity.data.length > 0) {
740
+ const firstItem = entity.data[0]
741
+ xAxisDataKey = firstItem.name || firstItem.x || firstItem.date || "name"
742
+ }
743
+ if (!xAxisDataKey) {
744
+ xAxisDataKey = "name"
745
+ }
746
+ children.push(chart.XAxis({ dataKey: xAxisDataKey }))
747
+
748
+ // YAxis
749
+ children.push(chart.YAxis({ width: "auto" }))
750
+
751
+ // Extract dataKeys from entity data
752
+ let dataKeys = []
753
+ if (isMultiSeries(entity.data)) {
754
+ // Multi-series: use series names as dataKeys
755
+ dataKeys = entity.data.map((series, idx) => {
756
+ // Try to get dataKey from series, or use index
757
+ return series.dataKey || series.name || series.label || `series${idx}`
758
+ })
759
+ } else {
760
+ // Single series: use "y" or "value" as dataKey
761
+ dataKeys = ["y", "value"].filter(
762
+ (key) =>
763
+ entity.data &&
764
+ entity.data.length > 0 &&
765
+ entity.data[0][key] !== undefined,
766
+ )
767
+ if (dataKeys.length === 0) {
768
+ dataKeys = ["value"] // Default fallback
769
+ }
770
+ }
771
+
772
+ // Lines (one per dataKey)
773
+ const colors = entity.colors || ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"]
774
+ dataKeys.forEach((dataKey, index) => {
775
+ children.push(
776
+ chart.Line({
777
+ dataKey,
778
+ stroke: colors[index % colors.length],
779
+ showDots: entity.showPoints !== false,
780
+ }),
781
+ )
782
+ })
783
+
784
+ // Dots (if showPoints is true)
785
+ if (entity.showPoints !== false && dataKeys.length > 0) {
786
+ dataKeys.forEach((dataKey, index) => {
787
+ children.push(
788
+ chart.Dots({
789
+ dataKey,
790
+ fill: colors[index % colors.length],
791
+ }),
792
+ )
793
+ })
794
+ }
795
+
796
+ // Tooltip
797
+ if (entity.showTooltip !== false) {
798
+ children.push(chart.Tooltip({}))
799
+ }
800
+
801
+ // Legend
802
+ if (entity.showLegend && isMultiSeries(entity.data)) {
803
+ children.push(
804
+ chart.Legend({
805
+ dataKeys,
806
+ labels: entity.labels || dataKeys,
807
+ colors: entity.colors,
808
+ }),
809
+ )
810
+ }
811
+
812
+ // Brush
813
+ if (entity.brush?.enabled) {
814
+ children.push(
815
+ chart.Brush({
816
+ dataKey: xAxisDataKey,
817
+ height: entity.brush.height || 30,
818
+ }),
819
+ )
820
+ }
821
+
822
+ return children
823
+ }