@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.
- package/LICENSE +9 -0
- package/README.md +554 -0
- package/package.json +64 -0
- package/src/base.css +86 -0
- package/src/cartesian/area.js +392 -0
- package/src/cartesian/area.test.js +366 -0
- package/src/cartesian/bar.js +445 -0
- package/src/cartesian/bar.test.js +346 -0
- package/src/cartesian/line.js +823 -0
- package/src/cartesian/line.test.js +177 -0
- package/src/chart.test.js +444 -0
- package/src/component/brush.js +264 -0
- package/src/component/empty-state.js +33 -0
- package/src/component/empty-state.test.js +81 -0
- package/src/component/grid.js +123 -0
- package/src/component/grid.test.js +123 -0
- package/src/component/legend.js +76 -0
- package/src/component/legend.test.js +103 -0
- package/src/component/tooltip.js +65 -0
- package/src/component/tooltip.test.js +96 -0
- package/src/component/x-axis.js +212 -0
- package/src/component/x-axis.test.js +148 -0
- package/src/component/y-axis.js +77 -0
- package/src/component/y-axis.test.js +107 -0
- package/src/handlers.js +150 -0
- package/src/index.js +264 -0
- package/src/polar/donut.js +181 -0
- package/src/polar/donut.test.js +152 -0
- package/src/polar/pie.js +758 -0
- package/src/polar/pie.test.js +268 -0
- package/src/shape/curve.js +55 -0
- package/src/shape/dot.js +104 -0
- package/src/shape/rectangle.js +46 -0
- package/src/shape/sector.js +58 -0
- package/src/template.js +25 -0
- package/src/theme.css +90 -0
- package/src/utils/cartesian-layout.js +164 -0
- package/src/utils/chart-utils.js +30 -0
- package/src/utils/colors.js +77 -0
- package/src/utils/data-utils.js +155 -0
- package/src/utils/data-utils.test.js +210 -0
- package/src/utils/extract-data-keys.js +22 -0
- package/src/utils/padding.js +16 -0
- package/src/utils/paths.js +279 -0
- package/src/utils/process-declarative-child.js +46 -0
- package/src/utils/scales.js +250 -0
- package/src/utils/shared-context.js +166 -0
- package/src/utils/shared-context.test.js +237 -0
- 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
|
+
}
|