Line Chart - Recharts Style (Composition with api.getEntity)
- ${chart.renderLineChart(
+ ${chart.render(
api.getEntity("salesLineChartComposition"),
{
width: 800,
height: 400,
dataKeys: ["value"],
children: [
- chart.CartesianGrid({
- stroke: "#eee",
- strokeDasharray: "5 5",
- }),
+ chart.CartesianGrid(),
chart.XAxis({ dataKey: "name" }),
- chart.YAxis({ width: "auto" }),
+ chart.YAxis(),
chart.Line({ dataKey: "value", stroke: "#8884d8" }),
chart.Dots({ dataKey: "value", fill: "#8884d8" }),
- chart.Tooltip({}),
+ chart.Tooltip(),
],
},
api,
@@ -38,24 +42,20 @@ export function renderLineSections(api, status) {
- Line Chart - Composition (Padding 0)
- ${chart.renderLineChart(
- api.getEntity("salesLineChartCompositionPadding"),
+ Line Chart - Composition (No entity)
+ ${chart.render(
{
- width: 800,
- height: 400,
- padding: { top: 10, right: -10, bottom: 10, left: 0 },
+ data: inlineLineData,
+ width: 600,
+ height: 240,
dataKeys: ["value"],
children: [
- chart.CartesianGrid({
- stroke: "#eee",
- strokeDasharray: "5 5",
- }),
+ chart.CartesianGrid(),
chart.XAxis({ dataKey: "name" }),
- chart.YAxis({ width: "auto" }),
- chart.Line({ dataKey: "value", stroke: "#8884d8" }),
- chart.Dots({ dataKey: "value", fill: "#8884d8" }),
- chart.Tooltip({}),
+ chart.YAxis(),
+ chart.Line({ dataKey: "value", stroke: "#2563eb" }),
+ chart.Dots({ dataKey: "value", fill: "#2563eb" }),
+ chart.Tooltip(),
],
},
api,
@@ -74,23 +74,20 @@ export function renderLineSections(api, status) {
Line Chart with Brush - Recharts Style (Composition with
api.getEntity)
- ${chart.renderLineChart(
+ ${chart.render(
api.getEntity("lineChartWithBrush"),
{
width: 800,
height: 400,
dataKeys: ["value"],
children: [
- chart.CartesianGrid({
- stroke: "#eee",
- strokeDasharray: "5 5",
- }),
+ chart.CartesianGrid(),
chart.XAxis({ dataKey: "name" }),
- chart.YAxis({ width: "auto" }),
+ chart.YAxis(),
chart.Line({ dataKey: "value", stroke: "#8884d8" }),
chart.Dots({ dataKey: "value", fill: "#8884d8" }),
- chart.Tooltip({}),
- chart.Brush({ height: 30 }),
+ chart.Tooltip(),
+ chart.Brush(),
],
},
api,
@@ -109,19 +106,16 @@ export function renderLineSections(api, status) {
Line Chart Multi Series - Recharts Style (Composition with
api.getEntity)
- ${chart.renderLineChart(
+ ${chart.render(
api.getEntity("multiSeriesLineChartComposition"),
{
width: 800,
height: 400,
dataKeys: ["productA", "productB", "productC", "productD"],
children: [
- chart.CartesianGrid({
- stroke: "#eee",
- strokeDasharray: "5 5",
- }),
+ chart.CartesianGrid(),
chart.XAxis({ dataKey: "name" }),
- chart.YAxis({ width: "auto" }),
+ chart.YAxis(),
chart.Line({
dataKey: "productA",
stroke: "#8884d8",
@@ -158,7 +152,7 @@ export function renderLineSections(api, status) {
dataKeys: ["productA", "productB", "productC", "productD"],
colors: ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"],
}),
- chart.Tooltip({}),
+ chart.Tooltip(),
],
},
api,
@@ -216,25 +210,20 @@ export function renderLineSections(api, status) {
Pause
- ${chart.renderLineChart(
+ ${chart.render(
api.getEntity("realtimeLineChart"),
{
width: 800,
height: 400,
dataKeys: ["value"],
children: [
- chart.CartesianGrid({
- stroke: "#eee",
- strokeDasharray: "5 5",
- }),
+ chart.CartesianGrid(),
chart.XAxis({ dataKey: "name" }),
- chart.YAxis({ width: "auto" }),
+ chart.YAxis(),
chart.Line({ dataKey: "value", stroke: "#2563eb" }),
chart.Dots({ dataKey: "value", fill: "#2563eb" }),
- chart.Tooltip({}),
- ...(isRealtimeCompositionPaused
- ? [chart.Brush({ height: 30 })]
- : []),
+ chart.Tooltip(),
+ ...(isRealtimeCompositionPaused ? [chart.Brush()] : []),
],
},
api,
diff --git a/examples/apps/web-charts/src/sections/pie.js b/examples/apps/web-charts/src/sections/pie.js
index 4c835a8e..c66d5122 100644
--- a/examples/apps/web-charts/src/sections/pie.js
+++ b/examples/apps/web-charts/src/sections/pie.js
@@ -2,6 +2,17 @@ import { html } from "@inglorious/web"
import { chart } from "@inglorious/charts"
export function renderPieSection(api) {
+ const inlinePieDataA = [
+ { name: "A", value: 20 },
+ { name: "B", value: 35 },
+ { name: "C", value: 15 },
+ ]
+ const inlinePieDataB = [
+ { name: "A", value: 10 },
+ { name: "B", value: 25 },
+ { name: "C", value: 40 },
+ ]
+
return html`
@@ -11,7 +22,7 @@ export function renderPieSection(api) {
Pie Chart - Recharts Style (Composition with api.getEntity)
- ${chart.renderPieChart(
+ ${chart.render(
api.getEntity("categoryPieChartComposition"),
{
width: 500,
@@ -31,5 +42,63 @@ export function renderPieSection(api) {
)}
+
+
+
+ Pie Chart - Composition (No id #1)
+ ${chart.render(
+ {
+ type: "pie",
+ data: inlinePieDataA,
+ width: 360,
+ height: 280,
+ showTooltip: true,
+ },
+ {
+ width: 360,
+ height: 280,
+ children: [
+ chart.Pie({
+ dataKey: "value",
+ nameKey: "name",
+ cx: "50%",
+ cy: "50%",
+ outerRadius: 90,
+ label: true,
+ }),
+ ],
+ },
+ api,
+ )}
+
+
+
+ Pie Chart - Composition (No id #2)
+ ${chart.render(
+ {
+ type: "pie",
+ data: inlinePieDataB,
+ width: 360,
+ height: 280,
+ showTooltip: true,
+ },
+ {
+ width: 360,
+ height: 280,
+ children: [
+ chart.Pie({
+ dataKey: "value",
+ nameKey: "name",
+ cx: "50%",
+ cy: "50%",
+ outerRadius: 90,
+ label: true,
+ }),
+ ],
+ },
+ api,
+ )}
+
+
`
}
diff --git a/examples/apps/web-charts/src/store/entities.js b/examples/apps/web-charts/src/store/entities.js
index 0adf8e43..36762559 100644
--- a/examples/apps/web-charts/src/store/entities.js
+++ b/examples/apps/web-charts/src/store/entities.js
@@ -196,17 +196,43 @@ export const entities = {
],
},
- // Area Chart - Padding - Composition Style
- salesAreaChartCompositionPadding: {
- type: "area",
- data: [
- { name: "0", value: 50 },
- { name: "1", value: 150 },
- { name: "2", value: 120 },
- { name: "3", value: 180 },
- { name: "4", value: 25 },
- { name: "5", value: 160 },
- { name: "6", value: 190 },
+ // Composed Area + Line + Bar - Config Style (same data as Composition below)
+ composedSalesChart: {
+ type: "composed",
+ data: [
+ { name: "Jan", revenue: 120, target: 80, forecast: 110 },
+ { name: "Feb", revenue: 180, target: 130, forecast: 150 },
+ { name: "Mar", revenue: 90, target: 140, forecast: 120 },
+ { name: "Apr", revenue: 210, target: 170, forecast: 190 },
+ { name: "May", revenue: 160, target: 220, forecast: 175 },
+ { name: "Jun", revenue: 200, target: 180, forecast: 195 },
+ { name: "Jul", revenue: 130, target: 190, forecast: 150 },
+ ],
+ width: 800,
+ height: 400,
+ showTooltip: true,
+ series: [
+ {
+ kind: "area",
+ dataKey: "revenue",
+ fill: "#8884d8",
+ fillOpacity: "0.3",
+ stroke: "#8884d8",
+ showDots: true,
+ showTooltip: true,
+ },
+ {
+ kind: "bar",
+ dataKey: "target",
+ fill: "#82ca9d",
+ showTooltip: true,
+ },
+ {
+ kind: "line",
+ dataKey: "forecast",
+ stroke: "#ff7300",
+ showDots: true,
+ },
],
},
diff --git a/examples/apps/web-charts/src/store/index.js b/examples/apps/web-charts/src/store/index.js
index 590c2597..ac325b3b 100644
--- a/examples/apps/web-charts/src/store/index.js
+++ b/examples/apps/web-charts/src/store/index.js
@@ -3,6 +3,7 @@ import {
areaChart,
barChart,
chart,
+ composedChart,
donutChart,
lineChart,
pieChart,
@@ -15,6 +16,7 @@ export const store = createStore({
area: areaChart,
line: lineChart,
bar: barChart,
+ composed: composedChart,
pie: pieChart,
donut: donutChart,
// Add chart object for composition methods
diff --git a/packages/charts/README.md b/packages/charts/README.md
index 00bdd98f..0cd06307 100644
--- a/packages/charts/README.md
+++ b/packages/charts/README.md
@@ -459,7 +459,12 @@ import { chart } from "@inglorious/charts"
// In your component
${chart(api, "multiSeriesChart", (c) =>
- c.renderLineChart([
+ c.renderLineChart({
+ width: 800,
+ height: 400,
+ dataKeys: ["productA", "productB"], // Required to sync Y-axis scale across multiple series
+ stacked: false, // Set to true to automatically sum values (Area/Bar)
+ }, [
c.renderLegend({
dataKeys: ["productA", "productB"],
labels: ["Product A", "Product B"],
@@ -468,12 +473,7 @@ ${chart(api, "multiSeriesChart", (c) =>
c.renderLine({ dataKey: "productA", stroke: "#8884d8", showDots: true }),
c.renderLine({ dataKey: "productB", stroke: "#82ca9d", showDots: true }),
c.renderTooltip({}),
- ], {
- width: 800,
- height: 400,
- dataKeys: ["productA", "productB"], // Required to sync Y-axis scale across multiple series
- stacked: false, // Set to true to automatically sum values (Area/Bar)
- })
+ ])
)}
```
diff --git a/packages/charts/src/cartesian/area.js b/packages/charts/src/cartesian/area.js
index e16ccde4..d47d96b2 100644
--- a/packages/charts/src/cartesian/area.js
+++ b/packages/charts/src/cartesian/area.js
@@ -1,93 +1,44 @@
/* eslint-disable no-magic-numbers */
-import { html, repeat, svg } from "@inglorious/web"
+import { repeat, svg } from "@inglorious/web"
import { createBrushComponent } from "../component/brush.js"
import { renderGrid } from "../component/grid.js"
import { renderLegend } from "../component/legend.js"
-import { createTooltipComponent, renderTooltip } from "../component/tooltip.js"
+import { createTooltipComponent } from "../component/tooltip.js"
import { renderXAxis } from "../component/x-axis.js"
import { renderYAxis } from "../component/y-axis.js"
import { chart } from "../index.js"
import { renderCurve } from "../shape/curve.js"
import { renderDot } from "../shape/dot.js"
-import { getTransformedData, isMultiSeries } from "../utils/data-utils.js"
-import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js"
+import {
+ createBandCenterScale,
+ getResolvedEntity,
+ inferSeriesDataKey,
+ resolveCategoryLabel,
+} from "../utils/cartesian-helpers.js"
+import { createCartesianRenderer } from "../utils/cartesian-renderer.js"
+import { PALETTE_DEFAULT } from "../utils/constants.js"
+import { getTransformedData } from "../utils/data-utils.js"
import {
generateAreaPath,
generateLinePath,
generateStackedAreaPath,
} from "../utils/paths.js"
-import { processDeclarativeChild } from "../utils/process-declarative-child.js"
-import { createSharedContext } from "../utils/shared-context.js"
import { createTooltipHandlers } from "../utils/tooltip-handlers.js"
+import { renderComposedChart } from "./composed.js"
export const area = {
- /**
- * Config-based rendering entry point.
- * Builds default composition children from entity options and delegates to
- * `renderAreaChart`.
- * @param {import('../types/charts').ChartEntity} entity
- * @param {import('@inglorious/web').Api} api
- * @returns {import('lit-html').TemplateResult}
- */
- render(entity, api) {
- const type = api.getType(entity.type)
-
- // Transform multi-series data to wide format if needed
- let transformedData = entity.data
- let dataKeys = []
-
- if (isMultiSeries(entity.data)) {
- // Extract dataKeys before transformation
- dataKeys = entity.data.map(
- (series, idx) =>
- series.dataKey || series.name || series.label || `series${idx}`,
- )
- // Transform to wide format for rendering
- transformedData = transformMultiSeriesToWide(entity.data)
- }
-
- // Create entity with transformed data
- const entityWithData = {
- ...entity,
- data: transformedData,
- dataKey:
- entity.dataKey || (transformedData[0]?.x !== undefined ? "x" : "name"),
- }
-
- // Convert entity config to declarative children
- const children = buildChildrenFromConfig(entityWithData, dataKeys)
-
- // Extract dataKeys for config if not already extracted
- if (dataKeys.length === 0) {
- if (entityWithData.data && entityWithData.data.length > 0) {
- const first = entityWithData.data[0]
- dataKeys = Object.keys(first).filter(
- (key) =>
- !["name", "x", "date"].includes(key) &&
- typeof first[key] === "number",
- )
- if (dataKeys.length === 0) {
- dataKeys = ["y", "value"].filter((k) => first[k] !== undefined)
- }
- if (dataKeys.length === 0) {
- dataKeys = ["value"]
- }
- }
- }
-
- return type.renderAreaChart(
- entityWithData,
- {
- width: entityWithData.width,
- height: entityWithData.height,
- stacked: entity.stacked === true,
- dataKeys: dataKeys.length > 0 ? dataKeys : undefined,
- children,
- },
- api,
- )
- },
+ render: createCartesianRenderer({
+ seriesType: "area",
+ chartApi: () => chart,
+ toDisplayData: ({ transformedData }) => transformedData,
+ buildRenderConfig: ({ entity, entityWithData, dataKeys }) => ({
+ width: entityWithData.width,
+ height: entityWithData.height,
+ stacked: entity.stacked === true,
+ dataKeys,
+ }),
+ }),
/**
* Composition rendering entry point for area charts.
@@ -96,125 +47,8 @@ export const area = {
* @param {import('@inglorious/web').Api} api
* @returns {import('lit-html').TemplateResult}
*/
- renderAreaChart(entity, params = {}, api) {
- if (!entity) return svg`Entity not found`
- const { children, ...config } = params
-
- const entityWithData = config.data
- ? { ...entity, data: config.data }
- : entity
-
- // Auto-detect stacked mode: if config.stacked is not explicitly set,
- // check if any Area component has a stackId
- let isStacked = config.stacked === true
- if (config.stacked === undefined) {
- const childrenArray = Array.isArray(children) ? children : [children]
- const hasStackId = childrenArray.some(
- (child) =>
- child &&
- typeof child === "object" &&
- child.type === "Area" &&
- child.config &&
- child.config.stackId !== undefined,
- )
- if (hasStackId) {
- isStacked = true
- config.stacked = true
- }
- }
-
- // Collect data keys (used for scales and legends)
- const dataKeysSet = new Set()
- if (config.dataKeys && Array.isArray(config.dataKeys)) {
- config.dataKeys.forEach((key) => dataKeysSet.add(key))
- } else if (children) {
- const autoDataKeys = extractDataKeysFromChildren(children)
- autoDataKeys.forEach((key) => dataKeysSet.add(key))
- }
-
- const context = createSharedContext(
- entityWithData,
- {
- width: config.width,
- height: config.height,
- padding: config.padding,
- chartType: "area",
- stacked: isStacked,
- usedDataKeys: dataKeysSet,
- filteredEntity: entityWithData,
- },
- api,
- )
- context.api = api
-
- if (isStacked) {
- context.stack = {
- sumsByStackId: new Map(),
- computedByKey: new Map(),
- }
- }
-
- const childrenArray = Array.isArray(children) ? children : [children]
-
- const processedChildrenArray = childrenArray
- .map((child) =>
- processDeclarativeChild(child, entityWithData, "area", api),
- )
- .filter(Boolean)
-
- const grid = [],
- axes = [],
- areas = [],
- dots = [],
- tooltip = [],
- legend = [],
- others = []
-
- for (const child of processedChildrenArray) {
- if (typeof child === "function") {
- if (child.isGrid) grid.push(child)
- else if (child.isAxis) axes.push(child)
- else if (child.isArea) areas.push(child)
- else if (child.isDots) dots.push(child)
- else if (child.isTooltip) tooltip.push(child)
- else if (child.isLegend) legend.push(child)
- else others.push(child)
- } else {
- others.push(child)
- }
- }
-
- const sortedChildren = [
- ...grid,
- ...(isStacked ? areas : [...areas].reverse()),
- ...axes,
- ...dots,
- ...tooltip,
- ...legend,
- ...others,
- ]
-
- const finalRendered = sortedChildren.map((child) => {
- if (typeof child !== "function") return child
- const result = child(context)
- return typeof result === "function" ? result(context) : result
- })
-
- return html`
-
-
- ${renderTooltip(entityWithData, {}, api)}
-
- `
+ renderAreaChart(entity, { children, config = {} }, api) {
+ return renderComposedChart(entity, { children, config }, api)
},
/**
@@ -228,7 +62,7 @@ export const area = {
const gridFn = (ctx) => {
const { xScale, yScale, dimensions } = ctx
return renderGrid(
- ctx.entity || entity,
+ getResolvedEntity(ctx, entity),
{ xScale, yScale, ...dimensions, ...config },
api,
)
@@ -247,7 +81,7 @@ export const area = {
renderXAxis(entity, { config = {} }, api) {
const axisFn = (ctx) => {
const { xScale, yScale, dimensions } = ctx
- const ent = ctx.entity || entity
+ const ent = getResolvedEntity(ctx, entity)
const labels = ent.data.map(
(d, i) => d[config.dataKey] || d.name || String(i),
)
@@ -275,11 +109,14 @@ export const area = {
* @param {import('@inglorious/web').Api} api
* @returns {(ctx: Record) => import('lit-html').TemplateResult}
*/
- // eslint-disable-next-line no-unused-vars
renderYAxis(entity, { config = {} }, api) {
const axisFn = (ctx) => {
const { yScale, dimensions } = ctx
- return renderYAxis(ctx.entity || entity, { yScale, ...dimensions }, api)
+ return renderYAxis(
+ getResolvedEntity(ctx, entity),
+ { yScale, ...dimensions, ...config },
+ api,
+ )
}
axisFn.isAxis = true
return axisFn
@@ -292,23 +129,51 @@ export const area = {
* @param {import('@inglorious/web').Api} api
* @returns {(ctx: Record) => import('lit-html').TemplateResult}
*/
- // eslint-disable-next-line no-unused-vars
renderArea(entity, { config = {} }, api) {
const areaFn = (ctx) => {
const { xScale, yScale } = ctx
const {
dataKey,
- fill = "#8884d8",
+ fill = PALETTE_DEFAULT[0],
fillOpacity = "0.6",
stroke,
stackId,
+ showDots = false,
+ showTooltip = undefined,
} = config
- const data = getTransformedData(ctx.entity || entity, dataKey)
+ const resolvedDataKey =
+ dataKey ??
+ (Array.isArray(config.data)
+ ? inferSeriesDataKey(config.data, "area")
+ : undefined)
+ const baseEntity = getResolvedEntity(ctx, entity)
+ const plotEntity = ctx.fullEntity || baseEntity
+ const indexOffset = ctx.indexOffset ?? 0
+ const indexEnd = ctx.indexEnd
+ const dataEntity = Array.isArray(config.data)
+ ? { ...baseEntity, data: config.data }
+ : baseEntity
+ const data = getTransformedData(dataEntity, resolvedDataKey)
if (!data) return svg``
const isStacked = Boolean(stackId) && Boolean(ctx.stack)
let areaPath, linePath
+ const scaleForSeries = xScale.bandwidth
+ ? createBandCenterScale(xScale)
+ : xScale
+ const chartData = data.map((d, i) => ({
+ ...d,
+ x: xScale.bandwidth
+ ? resolveCategoryLabel(plotEntity, i + indexOffset)
+ : i + indexOffset,
+ }))
+
+ const clippedChartData =
+ typeof indexEnd === "number"
+ ? chartData.filter((point) => point.x <= indexEnd)
+ : chartData
+
if (isStacked) {
const stackKey = String(stackId)
const sums =
@@ -319,21 +184,41 @@ export const area = {
seriesStack.map((p) => p[1]),
)
ctx.stack.computedByKey.set(`${stackKey}:${dataKey}`, seriesStack)
- areaPath = generateStackedAreaPath(data, xScale, yScale, seriesStack)
+ areaPath = generateStackedAreaPath(
+ clippedChartData,
+ scaleForSeries,
+ yScale,
+ seriesStack,
+ )
linePath = generateLinePath(
- data.map((d, i) => ({ ...d, y: seriesStack[i][1] })),
- xScale,
+ clippedChartData.map((d, i) => ({ ...d, y: seriesStack[i][1] })),
+ scaleForSeries,
yScale,
)
} else {
- areaPath = generateAreaPath(data, xScale, yScale, 0)
- linePath = generateLinePath(data, xScale, yScale)
+ areaPath = generateAreaPath(clippedChartData, scaleForSeries, yScale, 0)
+ linePath = generateLinePath(clippedChartData, scaleForSeries, yScale)
}
return svg`
${renderCurve({ d: areaPath, fill, fillOpacity })}
${linePath ? renderCurve({ d: linePath, stroke: stroke || fill }) : ""}
+ ${
+ showDots || showTooltip
+ ? area.renderDots(
+ dataEntity,
+ {
+ config: {
+ ...config,
+ fill: stroke || fill,
+ showTooltip,
+ },
+ },
+ api,
+ )(ctx)
+ : ""
+ }
`
}
areaFn.isArea = true
@@ -350,9 +235,23 @@ export const area = {
renderDots(entity, { config = {} }, api) {
const dotsFn = (ctx) => {
const { xScale, yScale } = ctx
- const entityFromContext = ctx.entity || entity
- const { dataKey, fill = "#8884d8" } = config
- const data = getTransformedData(entityFromContext, dataKey)
+ const entityFromContext = getResolvedEntity(ctx, entity)
+ const { dataKey, fill = PALETTE_DEFAULT[0] } = config
+ const resolvedDataKey =
+ dataKey ??
+ (Array.isArray(config.data)
+ ? inferSeriesDataKey(config.data, "area")
+ : undefined)
+ const dataEntity = Array.isArray(config.data)
+ ? { ...entityFromContext, data: config.data }
+ : entityFromContext
+ const plotEntity = ctx.fullEntity || dataEntity
+ const indexOffset = ctx.indexOffset ?? 0
+ const indexEnd = ctx.indexEnd
+ const data = getTransformedData(dataEntity, resolvedDataKey)
+ const scaleForSeries = xScale.bandwidth
+ ? createBandCenterScale(xScale)
+ : xScale
if (!data || data.length === 0) return svg``
const seriesStack = ctx.stack?.computedByKey.get(
@@ -363,12 +262,20 @@ export const area = {
${repeat(
data,
- (d, i) => `${dataKey}-${i}`,
+ (d, i) => `${resolvedDataKey || "value"}-${i}`,
(d, i) => {
- const x = xScale(d.x)
+ const resolvedIndex = i + indexOffset
+ if (typeof indexEnd === "number" && resolvedIndex > indexEnd) {
+ return svg``
+ }
+ const x = scaleForSeries(
+ xScale.bandwidth
+ ? resolveCategoryLabel(plotEntity, resolvedIndex)
+ : resolvedIndex,
+ )
const y = yScale(seriesStack ? seriesStack[i]?.[1] : d.y)
// Get label from original data point (like line chart does)
- const originalDataPoint = entityFromContext.data[i]
+ const originalDataPoint = plotEntity.data[resolvedIndex]
const label =
originalDataPoint?.name ||
originalDataPoint?.label ||
@@ -379,6 +286,11 @@ export const area = {
entity: entityFromContext,
api: ctx.api || api,
tooltipData: { label, value, color: fill },
+ enabled:
+ config.showTooltip ??
+ (ctx.tooltipMode
+ ? ctx.tooltipMode === "all"
+ : ctx.tooltipEnabled),
})
return renderDot({
cx: x,
@@ -417,7 +329,7 @@ export const area = {
}))
return renderLegend(
- ctx.entity || entity,
+ getResolvedEntity(ctx, entity),
{
series,
colors: colors || [],
@@ -437,120 +349,3 @@ export const area = {
*/
renderBrush: createBrushComponent(),
}
-
-/**
- * Builds declarative children from entity config for render (config style)
- * Converts entity configuration into children objects that renderAreaChart can process
- * @param {import('../types/charts').ChartEntity} entity
- * @param {string[]} [providedDataKeys] - Optional dataKeys if already extracted (e.g., from multi-series before transformation)
- */
-function buildChildrenFromConfig(entity, providedDataKeys = null) {
- const children = []
-
- if (entity.showGrid !== false) {
- children.push(
- chart.CartesianGrid({ stroke: "#eee", strokeDasharray: "5 5" }),
- )
- }
-
- // XAxis - determine dataKey from entity or data structure
- let xAxisDataKey = entity.dataKey
- if (!xAxisDataKey && entity.data?.length > 0) {
- const first = entity.data[0]
- xAxisDataKey = first.name || first.x || first.date || "name"
- }
- if (!xAxisDataKey) {
- xAxisDataKey = "name"
- }
- children.push(chart.XAxis({ dataKey: xAxisDataKey }))
- children.push(chart.YAxis({ width: "auto" }))
-
- // Extract dataKeys from entity data or use provided ones
- let dataKeys = providedDataKeys
- if (!dataKeys || dataKeys.length === 0) {
- if (isMultiSeries(entity.data)) {
- // Multi-series: use series names as dataKeys
- dataKeys = entity.data.map((s, i) => s.dataKey || s.name || `series${i}`)
- } else if (entity.data?.length > 0) {
- // Wide format: extract numeric keys
- const first = entity.data[0]
- dataKeys = Object.keys(first).filter(
- (key) =>
- !["name", "x", "date"].includes(key) &&
- typeof first[key] === "number",
- )
- if (dataKeys.length === 0) {
- dataKeys = ["y", "value"].filter((k) => first[k] !== undefined)
- }
- if (dataKeys.length === 0) {
- dataKeys = ["value"]
- }
- } else {
- dataKeys = ["value"]
- }
- }
-
- const colors = entity.colors || ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"]
- const isStacked = entity.stacked === true
-
- dataKeys.forEach((key, i) => {
- children.push(
- chart.Area({
- dataKey: key,
- fill: colors[i % colors.length],
- fillOpacity: "0.6",
- stroke: colors[i % colors.length],
- stackId: isStacked ? "1" : undefined,
- }),
- )
- })
-
- if (entity.showPoints !== false) {
- dataKeys.forEach((key, i) => {
- children.push(
- chart.Dots({
- dataKey: key,
- fill: colors[i % colors.length],
- stackId: isStacked ? "1" : undefined,
- }),
- )
- })
- }
-
- if (entity.showTooltip !== false) children.push(chart.Tooltip({}))
-
- // Legend
- if (entity.showLegend === true && dataKeys.length > 1) {
- children.push(
- chart.Legend({ dataKeys, colors: colors.slice(0, dataKeys.length) }),
- )
- }
-
- if (entity.brush?.enabled) {
- children.push(chart.Brush({ dataKey: xAxisDataKey || "name" }))
- }
-
- return children
-}
-
-function transformMultiSeriesToWide(multiSeriesData) {
- if (!isMultiSeries(multiSeriesData)) return null
- const dataMap = new Map()
- const seriesKeys = multiSeriesData.map((s) => s.dataKey || s.name || "series")
-
- multiSeriesData.forEach((series, seriesIdx) => {
- const key = seriesKeys[seriesIdx]
- const values = Array.isArray(series.values) ? series.values : [series]
- values.forEach((point, index) => {
- const xVal = point?.x ?? index
- if (!dataMap.has(xVal)) dataMap.set(xVal, { x: xVal, name: String(xVal) })
- dataMap.get(xVal)[key] = point?.y ?? point?.value ?? 0
- })
- })
-
- return Array.from(dataMap.values()).sort((a, b) => {
- return typeof a.x === "number"
- ? a.x - b.x
- : String(a.x).localeCompare(String(b.x))
- })
-}
diff --git a/packages/charts/src/cartesian/area.test.js b/packages/charts/src/cartesian/area.test.js
index 5d20d032..63cf25c7 100644
--- a/packages/charts/src/cartesian/area.test.js
+++ b/packages/charts/src/cartesian/area.test.js
@@ -168,9 +168,11 @@ describe("area", () => {
entity,
{
children,
- width: 800,
- height: 400,
- padding: { top: 0, right: 0, bottom: 0, left: 0 },
+ config: {
+ width: 800,
+ height: 400,
+ padding: { top: 0, right: 0, bottom: 0, left: 0 },
+ },
},
api,
)
diff --git a/packages/charts/src/cartesian/bar.js b/packages/charts/src/cartesian/bar.js
index 86d6b773..fd7ccbe0 100644
--- a/packages/charts/src/cartesian/bar.js
+++ b/packages/charts/src/cartesian/bar.js
@@ -1,19 +1,22 @@
/* eslint-disable no-magic-numbers */
-import { html, svg } from "@inglorious/web"
-import { extent } from "d3-array"
+import { svg } from "@inglorious/web"
import { scaleBand } from "d3-scale"
import { createBrushComponent } from "../component/brush.js"
import { renderGrid } from "../component/grid.js"
-import { createTooltipComponent, renderTooltip } from "../component/tooltip.js"
+import { createTooltipComponent } from "../component/tooltip.js"
import { renderXAxis } from "../component/x-axis.js"
import { renderYAxis } from "../component/y-axis.js"
+import { chart } from "../index.js"
import { renderRectangle } from "../shape/rectangle.js"
-import { renderCartesianLayout } from "../utils/cartesian-layout.js"
-import { resolveChartDimensions } from "../utils/chart-dimensions.js"
-import { processDeclarativeChild } from "../utils/process-declarative-child.js"
-import { createCartesianContext } from "../utils/scales.js"
+import { buildCartesianBaseChildren } from "../utils/cartesian-children.js"
+import {
+ getResolvedEntity,
+ inferSeriesDataKey,
+} from "../utils/cartesian-helpers.js"
+import { DEFAULT_GRID_CONFIG, PALETTE_DEFAULT } from "../utils/constants.js"
import { createTooltipHandlers } from "../utils/tooltip-handlers.js"
+import { renderComposedChart } from "./composed.js"
export const bar = {
/**
@@ -26,36 +29,19 @@ export const bar = {
*/
render(entity, api) {
const type = api.getType(entity.type)
- const children = [
- entity.showGrid !== false
- ? type.renderCartesianGrid(entity, {}, api)
- : null,
- type.renderXAxis(entity, {}, api),
- type.renderYAxis(entity, {}, api),
- type.renderBar(
- entity,
- { config: { dataKey: "value", multiColor: false } },
- api,
- ),
- ].filter(Boolean)
+ const children = buildCartesianBaseChildren(entity, {
+ makeChild: (typeKey, config) => chart[typeKey](config),
+ })
+ children.push(chart.Bar({ dataKey: "value", multiColor: false }))
- const chartContent = type.renderBarChart(
+ return type.renderBarChart(
entity,
{
- width: entity.width,
- height: entity.height,
- isRawSVG: true,
children,
- },
- api,
- )
-
- return renderCartesianLayout(
- entity,
- {
- chartType: "bar",
- chartContent,
- showLegend: false,
+ config: {
+ width: entity.width,
+ height: entity.height,
+ },
},
api,
)
@@ -68,187 +54,8 @@ export const bar = {
* @param {import('@inglorious/web').Api} api
* @returns {import('lit-html').TemplateResult}
*/
- renderBarChart(entity, params = {}, api) {
- if (!entity) return html`Entity not found
`
- const { children, ...config } = params
- if (!entity.data || !Array.isArray(entity.data)) {
- return html`Entity data is missing or invalid
`
- }
-
- const { width, height, padding } = resolveChartDimensions({
- configWidth: config.width,
- configHeight: config.height,
- configPadding: config.padding,
- entityWidth: entity.width,
- entityHeight: entity.height,
- entityPadding: entity.padding,
- })
-
- const childrenArray = (
- Array.isArray(children) ? children : [children]
- ).filter(Boolean)
-
- const processedChildrenArray = childrenArray
- .map((child) => processDeclarativeChild(child, entity, "bar", api))
- .filter(Boolean)
-
- // Separate components using stable flags (survives minification)
- // This ensures correct Z-index ordering: Grid -> Bars -> Axes
- const grid = []
- const axes = []
- const bars = []
- const tooltip = []
- const others = []
-
- for (const child of processedChildrenArray) {
- // Use stable flags instead of string matching (survives minification)
- if (typeof child === "function") {
- // If it's already marked, add to the correct bucket
- if (child.isGrid) {
- grid.push(child)
- } else if (child.isAxis) {
- axes.push(child)
- } else if (child.isBar) {
- bars.push(child)
- } else if (child.isTooltip) {
- tooltip.push(child)
- } else {
- // It's a lazy function from index.js - we'll identify its type during processing
- // For now, add to others - it will be processed correctly in the final loop
- others.push(child)
- }
- } else {
- others.push(child)
- }
- }
-
- // Store barComponents for Y-axis calculation
- const barComponents = bars
-
- // 2. FUNDAMENTAL SCALE - Crucial for alignment
- const categories = entity.data.map(
- (d) => d.label || d.name || d.category || "",
- )
- const xScale = scaleBand()
- .domain(categories)
- .range([padding.left, width - padding.right])
- .padding(0.1)
-
- const context = createCartesianContext(
- { ...entity, width, height, padding },
- "bar",
- )
- context.xScale = xScale
- context.dimensions = { width, height, padding }
- context.chartType = "bar" // Include chartType for lazy components
-
- // 3. Identify data keys for Y-axis
- const dataKeys =
- config.dataKeys || barComponents.map((c) => c.dataKey || "value")
-
- const allValues = entity.data.flatMap((d) =>
- dataKeys.map((k) => d[k]).filter((v) => typeof v === "number"),
- )
- if (allValues.length > 0) {
- const [minVal, maxVal] = extent(allValues)
- context.yScale.domain([Math.min(0, minVal), maxVal]).nice()
- }
-
- // 4. Process children from 'others' to identify their real types (lazy functions from index.js)
- // This ensures grid/axes from index.js are placed in the correct buckets
- const identifiedGrid = []
- const identifiedAxes = []
- const remainingOthers = []
-
- for (const child of others) {
- if (typeof child === "function") {
- try {
- const result = child(context)
- if (typeof result === "function") {
- if (result.isGrid) {
- identifiedGrid.push(child) // Keep the original lazy function
- } else if (result.isAxis) {
- identifiedAxes.push(child)
- } else {
- remainingOthers.push(child)
- }
- } else {
- remainingOthers.push(child)
- }
- } catch {
- remainingOthers.push(child)
- }
- } else {
- remainingOthers.push(child)
- }
- }
-
- // Reorder children for correct Z-index: Grid -> Bars -> Axes -> Tooltip -> Others
- // This ensures grid is behind, bars are in the middle, and axes are on top
- const childrenToProcess = [
- ...grid,
- ...identifiedGrid, // Grids identified from others
- ...bars,
- ...axes,
- ...identifiedAxes, // Axes identified from others
- ...tooltip,
- ...remainingOthers,
- ]
-
- // Process children to handle lazy functions (like renderCartesianGrid from index.js)
- // Flow:
- // 1. renderCartesianGrid/renderXAxis from index.js return (ctx) => { return chartType.renderCartesianGrid(...) }
- // 2. chartType.renderCartesianGrid (from bar.js) returns gridFn which is (ctx) => { return svg... }
- // 3. So we need: child(context) -> gridFn, then gridFn(context) -> svg
- // Simplified deterministic approach: all functions from index.js return (ctx) => ..., so we can safely call with context
- const processedChildren = childrenToProcess.map((child) => {
- // Non-function children are passed through as-is
- if (typeof child !== "function") {
- return child
- }
-
- // If it's a marked component (isGrid, isBar, isAxis, etc), it expects context directly
- if (child.isGrid || child.isAxis || child.isBar || child.isTooltip) {
- // For bars, also pass barIndex and totalBars
- if (child.isBar) {
- const barIndex = barComponents.indexOf(child)
- return child(context, barIndex, barComponents.length)
- }
- return child(context)
- }
-
- // If it's a function from index.js (renderCartesianGrid, etc),
- // it returns another function that also expects context
- const result = child(context)
- // If the result is a function (marked component), call it with context
- if (typeof result === "function") {
- // For bars, also pass barIndex and totalBars
- if (result.isBar) {
- const barIndex = barComponents.indexOf(result)
- return result(context, barIndex, barComponents.length)
- }
- return result(context)
- }
- // Otherwise, return the result directly (already SVG or TemplateResult)
- return result
- })
-
- const svgContent = svg`
-
- `
-
- if (config.isRawSVG) return svgContent
-
- return html`
-
- ${svgContent} ${renderTooltip(entity, {}, api)}
-
- `
+ renderBarChart(entity, { children, config = {} }, api) {
+ return renderComposedChart(entity, { children, config }, api)
},
/**
@@ -260,16 +67,19 @@ export const bar = {
*/
renderBar(entity, { config = {} }, api) {
// Preserve config values in closure
- const { dataKey = "value", fill, multiColor = false } = config
+ const { dataKey, fill, multiColor = false } = config
+ const resolvedDataKey =
+ dataKey ??
+ (Array.isArray(config.data)
+ ? inferSeriesDataKey(config.data, "bar")
+ : "value")
const drawFn = (ctx, barIndex, totalBars) => {
- const entityFromContext = ctx.entity || entity
+ const entityFromContext = getResolvedEntity(ctx, entity)
if (!entityFromContext) return svg``
- const entityColors = entityFromContext.colors || [
- "#8884d8",
- "#82ca9d",
- "#ffc658",
- "#ff7300",
- ]
+ const dataSource = Array.isArray(config.data)
+ ? config.data
+ : entityFromContext.data
+ const entityColors = entityFromContext.colors || PALETTE_DEFAULT
const { xScale, yScale, dimensions } = ctx
// When there's only one bar, center it in the band without using subScale
@@ -290,9 +100,10 @@ export const bar = {
}
return svg`
- ${entityFromContext.data.map((d, i) => {
- const category = d.label || d.name || d.category || String(i)
- const value = d[dataKey] ?? 0
+ ${dataSource.map((d, i) => {
+ const category = d.label || d.name || d.category
+ const label = category ?? String(i)
+ const value = d[resolvedDataKey] ?? 0
const bandStart = xScale(category)
// Skip if bandStart is undefined or NaN (invalid category)
@@ -319,7 +130,12 @@ export const bar = {
const { onMouseEnter, onMouseLeave } = createTooltipHandlers({
entity: entityFromContext,
api,
- tooltipData: { label: category, value, color },
+ tooltipData: { label, value, color },
+ enabled:
+ config.showTooltip ??
+ (ctx.tooltipMode
+ ? ctx.tooltipMode === "all"
+ : ctx.tooltipEnabled),
})
return renderRectangle({
x,
@@ -335,7 +151,7 @@ export const bar = {
}
drawFn.isBar = true
- drawFn.dataKey = config.dataKey || "value"
+ drawFn.dataKey = resolvedDataKey || "value"
return drawFn
},
@@ -350,7 +166,7 @@ export const bar = {
// Return a function that preserves the original object
// This prevents lit-html from evaluating the function before passing it
const renderFn = (ctx) => {
- const entityFromContext = ctx.entity || entity
+ const entityFromContext = getResolvedEntity(ctx, entity)
if (!entityFromContext) return svg``
return renderXAxis(
entityFromContext,
@@ -389,7 +205,7 @@ export const bar = {
*/
renderYAxis(entity, props, api) {
const axisFn = (ctx) => {
- const entityFromContext = ctx.entity || entity
+ const entityFromContext = getResolvedEntity(ctx, entity)
return renderYAxis(
entityFromContext,
{
@@ -416,7 +232,7 @@ export const bar = {
*/
renderCartesianGrid(entity, { config = {} }, api) {
const gridFn = (ctx) => {
- const entityFromContext = ctx.entity || entity
+ const entityFromContext = getResolvedEntity(ctx, entity)
if (!entityFromContext) return svg``
return renderGrid(
entityFromContext,
@@ -424,8 +240,9 @@ export const bar = {
...ctx.dimensions,
xScale: ctx.xScale,
yScale: ctx.yScale,
- stroke: config.stroke || "#eee",
- strokeDasharray: config.strokeDasharray || "5 5",
+ stroke: config.stroke || DEFAULT_GRID_CONFIG.stroke,
+ strokeDasharray:
+ config.strokeDasharray || DEFAULT_GRID_CONFIG.strokeDasharray,
},
api,
)
diff --git a/packages/charts/src/cartesian/bar.test.js b/packages/charts/src/cartesian/bar.test.js
index 648f1d49..4e590dff 100644
--- a/packages/charts/src/cartesian/bar.test.js
+++ b/packages/charts/src/cartesian/bar.test.js
@@ -109,14 +109,10 @@ describe("bar", () => {
expect(container.textContent).toContain("Entity not found")
})
- it("should return error message if entity.data is invalid", () => {
+ it("should throw if entity.data is invalid", () => {
entity.data = null
- const result = bar.renderBarChart(entity, { children: [] }, api)
- const container = document.createElement("div")
- render(result, container)
-
- expect(container.textContent).toContain("Entity data is missing")
+ expect(() => bar.renderBarChart(entity, { children: [] }, api)).toThrow()
})
it("should respect custom zero padding in composition config", () => {
@@ -132,9 +128,11 @@ describe("bar", () => {
entity,
{
children,
- width: 800,
- height: 400,
- padding: { top: 0, right: 0, bottom: 0, left: 0 },
+ config: {
+ width: 800,
+ height: 400,
+ padding: { top: 0, right: 0, bottom: 0, left: 0 },
+ },
},
api,
)
diff --git a/packages/charts/src/cartesian/composed.js b/packages/charts/src/cartesian/composed.js
new file mode 100644
index 00000000..bd583f33
--- /dev/null
+++ b/packages/charts/src/cartesian/composed.js
@@ -0,0 +1,366 @@
+/* eslint-disable no-magic-numbers */
+import { html, svg } from "@inglorious/web"
+
+import { renderTooltip } from "../component/tooltip.js"
+import {
+ buildCartesianBaseChildren,
+ resolveXAxisDataKey,
+} from "../utils/cartesian-children.js"
+import {
+ DEFAULT_SERIES_INDEX,
+ inferSeriesDataKey,
+} from "../utils/cartesian-helpers.js"
+import { sortChildrenByLayer } from "../utils/cartesian-renderer.js"
+import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js"
+import { processDeclarativeChild } from "../utils/process-declarative-child.js"
+import { ensureChartRuntimeId } from "../utils/runtime-id.js"
+import { getFilteredData } from "../utils/scales.js"
+import { createSharedContext } from "../utils/shared-context.js"
+import { createTooltipMoveHandler } from "../utils/tooltip-handlers.js"
+
+const CARTESIAN_SERIES = new Set(["Line", "Area", "Bar"])
+const KIND_TO_TYPE = {
+ area: "Area",
+ bar: "Bar",
+ line: "Line",
+}
+const DEFAULT_SERIES_VALUE = DEFAULT_SERIES_INDEX
+const DEFAULT_INDEX_STEP = 1
+
+export const composed = {
+ render: renderComposedConfig,
+}
+
+export function renderComposedChart(entity, { children, config = {} }, api) {
+ if (!entity) return svg`Entity not found`
+
+ const entityWithData = config.data
+ ? entity?.__inline
+ ? Object.assign(entity, { data: config.data })
+ : { ...entity, data: config.data }
+ : entity
+ const childrenArray = Array.isArray(children) ? children : [children]
+
+ const seriesTypes = new Set(
+ childrenArray
+ .map((child) => {
+ if (!child) return null
+ if (child.type && CARTESIAN_SERIES.has(child.type)) return child.type
+ if (typeof child === "function") {
+ if (child.isBar) return "Bar"
+ if (child.isArea) return "Area"
+ if (child.isLine) return "Line"
+ }
+ return null
+ })
+ .filter(Boolean),
+ )
+
+ const hasBarSeries = seriesTypes.has("Bar")
+ const hasAreaSeries = seriesTypes.has("Area")
+ const hasLineSeries = seriesTypes.has("Line")
+ const hasBrush = childrenArray.some(
+ (child) => child?.type === "Brush" || child?.isBrush,
+ )
+
+ const inferredChartType = hasBarSeries
+ ? "bar"
+ : hasAreaSeries
+ ? "area"
+ : "line"
+
+ let isStacked = config.stacked === true
+ if (config.stacked === undefined && hasAreaSeries) {
+ const hasStackId = childrenArray.some(
+ (child) =>
+ child &&
+ child.type === "Area" &&
+ child.config &&
+ child.config.stackId !== undefined,
+ )
+ if (hasStackId) {
+ isStacked = true
+ config.stacked = true
+ }
+ }
+
+ const dataKeysSet = new Set()
+ if (config.dataKeys && Array.isArray(config.dataKeys)) {
+ config.dataKeys.forEach((key) => dataKeysSet.add(key))
+ } else if (childrenArray.length) {
+ const autoDataKeys = extractDataKeysFromChildren(childrenArray)
+ autoDataKeys.forEach((key) => dataKeysSet.add(key))
+ }
+
+ const baseEntity = entityWithData
+ const seriesChildrenWithData = childrenArray.filter(
+ (child) =>
+ child &&
+ CARTESIAN_SERIES.has(child.type) &&
+ Array.isArray(child.config?.data),
+ )
+ const composedData =
+ seriesChildrenWithData.length > DEFAULT_SERIES_VALUE
+ ? mergeComposedData(entityWithData.data, seriesChildrenWithData)
+ : entityWithData.data
+ const contextEntity =
+ composedData.length > DEFAULT_SERIES_VALUE
+ ? entityWithData.__inline
+ ? Object.assign(entityWithData, { data: composedData })
+ : { ...entityWithData, data: composedData }
+ : entityWithData
+
+ const brushSource = config.originalEntity || baseEntity
+ const brush = brushSource?.brush
+ const shouldFilter =
+ (inferredChartType === "line" || inferredChartType === "area") &&
+ brush?.enabled &&
+ !config.originalEntity
+ const filteredEntity = shouldFilter
+ ? { ...contextEntity, data: getFilteredData({ ...contextEntity, brush }) }
+ : contextEntity
+
+ const hasTooltip = childrenArray.some(
+ (child) => child?.type === "Tooltip" || child?.type === "renderTooltip",
+ )
+ const hasSeriesTooltip = childrenArray.some(
+ (child) => child?.config?.showTooltip === true,
+ )
+ const tooltipEnabled = hasTooltip || hasSeriesTooltip
+ const tooltipMode = hasTooltip ? "all" : hasSeriesTooltip ? "series" : "none"
+
+ if (tooltipEnabled) {
+ if (contextEntity?.showTooltip === undefined) {
+ contextEntity.showTooltip = true
+ }
+ } else if (contextEntity) {
+ contextEntity.showTooltip = false
+ contextEntity.tooltip = null
+ }
+
+ const context = createSharedContext(
+ contextEntity,
+ {
+ width: config.width,
+ height: config.height,
+ padding: config.padding,
+ chartType: inferredChartType,
+ stacked: isStacked,
+ usedDataKeys: dataKeysSet,
+ filteredEntity,
+ },
+ api,
+ )
+ context.api = api
+ context.tooltipEnabled = tooltipEnabled
+ context.tooltipMode = tooltipMode
+ context.fullEntity = brushSource
+ context.indexOffset =
+ shouldFilter && brush?.startIndex !== undefined
+ ? brush.startIndex
+ : DEFAULT_SERIES_INDEX
+ context.indexEnd =
+ shouldFilter && brush?.endIndex !== undefined ? brush.endIndex : undefined
+ if (
+ (inferredChartType === "line" || inferredChartType === "area") &&
+ brush?.enabled &&
+ brush.startIndex !== undefined
+ ) {
+ const endIndex =
+ brush.endIndex ??
+ Math.max(DEFAULT_SERIES_INDEX, brushSource.data.length - 1)
+ if (config.originalEntity) {
+ context.xScale.domain([
+ DEFAULT_SERIES_INDEX,
+ Math.max(DEFAULT_SERIES_INDEX, contextEntity.data.length - 1),
+ ])
+ } else {
+ context.xScale.domain([brush.startIndex, endIndex])
+ }
+ }
+
+ if (isStacked) {
+ context.stack = {
+ sumsByStackId: new Map(),
+ computedByKey: new Map(),
+ }
+ }
+
+ const clipPathId = hasLineSeries
+ ? `chart-clip-${ensureChartRuntimeId(contextEntity)}`
+ : null
+ if (clipPathId) context.clipPathId = clipPathId
+
+ const processedChildrenArray = childrenArray
+ .map((child) => {
+ const targetEntity =
+ child && child.type === "Brush" ? context.fullEntity : contextEntity
+ return processDeclarativeChild(
+ child,
+ targetEntity,
+ inferredChartType,
+ api,
+ )
+ })
+ .filter(Boolean)
+
+ const { orderedChildren } = sortChildrenByLayer(processedChildrenArray, {
+ seriesFlag: ["isArea", "isBar", "isLine"],
+ // For area charts, render higher-value series first so lower ones stay visible on top
+ reverseSeries: inferredChartType === "area",
+ includeBrush: hasBrush,
+ })
+
+ const barSeries = orderedChildren.filter(
+ (child) => typeof child === "function" && child.isBar,
+ )
+
+ const finalRendered = orderedChildren.map((child) => {
+ if (typeof child !== "function") return child
+ if (child.isBar) {
+ const barIndex = barSeries.indexOf(child)
+ return child(context, barIndex, barSeries.length)
+ }
+ const result = child(context)
+ return typeof result === "function" ? result(context) : result
+ })
+
+ return html`
+
+
+ ${renderTooltip(contextEntity, {}, api)}
+
+ `
+}
+
+export function buildComposedChildren(entity) {
+ const children = buildCartesianBaseChildren(entity, {
+ includeTooltip: false,
+ includeBrush: false,
+ })
+
+ const series = Array.isArray(entity.series) ? entity.series : []
+ series.forEach((item) => {
+ if (!item || typeof item !== "object") return
+ const kind = (item.kind || item.type || "").toLowerCase()
+ const type = KIND_TO_TYPE[kind]
+ if (!type) return
+ // eslint-disable-next-line no-unused-vars
+ const { kind: kindValue, type: typeValue, ...config } = item
+ children.push({ type, config })
+ })
+
+ if (entity.showTooltip !== false) {
+ children.push({ type: "Tooltip", config: {} })
+ }
+
+ if (entity.brush?.enabled && entity.brush?.visible !== false) {
+ children.push({
+ type: "Brush",
+ config: {
+ dataKey: resolveXAxisDataKey(entity),
+ height: entity.brush.height || 30,
+ },
+ })
+ }
+
+ return children
+}
+
+function renderComposedConfig(entity, api) {
+ if (!entity) return svg`Entity not found`
+ const children = buildComposedChildren(entity)
+ return renderComposedChart(
+ entity,
+ {
+ children,
+ config: {
+ width: entity.width,
+ height: entity.height,
+ padding: entity.padding,
+ },
+ },
+ api,
+ )
+}
+
+function mergeComposedData(baseData, seriesChildren) {
+ const merged = Array.isArray(baseData)
+ ? baseData.map((item) => ({ ...item }))
+ : []
+
+ let maxLength = merged.length
+ for (const child of seriesChildren) {
+ const data = child?.config?.data
+ if (Array.isArray(data)) {
+ maxLength = Math.max(maxLength, data.length)
+ }
+ }
+
+ if (maxLength === DEFAULT_SERIES_VALUE) return merged
+
+ for (let i = merged.length; i < maxLength; i += DEFAULT_INDEX_STEP) {
+ merged[i] = {}
+ }
+
+ for (const child of seriesChildren) {
+ const data = child?.config?.data
+ const dataKey =
+ child?.config?.dataKey ??
+ inferSeriesDataKey(data, child?.type?.toLowerCase())
+ if (!Array.isArray(data) || !dataKey) continue
+
+ data.forEach((point, index) => {
+ const target = merged[index] || (merged[index] = {})
+ if (target.name == null && point?.name != null) target.name = point.name
+ if (target.label == null && point?.label != null)
+ target.label = point.label
+ if (target.x == null && point?.x != null) target.x = point.x
+ if (target.date == null && point?.date != null) target.date = point.date
+
+ const value =
+ typeof point?.[dataKey] === "number"
+ ? point[dataKey]
+ : typeof point?.value === "number"
+ ? point.value
+ : typeof point?.y === "number"
+ ? point.y
+ : undefined
+ if (typeof value === "number") {
+ target[dataKey] = value
+ }
+ })
+ }
+
+ return merged
+}
diff --git a/packages/charts/src/cartesian/line.js b/packages/charts/src/cartesian/line.js
index af04f3ff..cb496b91 100644
--- a/packages/charts/src/cartesian/line.js
+++ b/packages/charts/src/cartesian/line.js
@@ -1,100 +1,49 @@
/* eslint-disable no-magic-numbers */
-import { html, repeat, svg } from "@inglorious/web"
+import { repeat, svg } from "@inglorious/web"
import { createBrushComponent } from "../component/brush.js"
import { renderGrid } from "../component/grid.js"
import { renderLegend } from "../component/legend.js"
-import { createTooltipComponent, renderTooltip } from "../component/tooltip.js"
+import { createTooltipComponent } from "../component/tooltip.js"
import { renderXAxis } from "../component/x-axis.js"
import { renderYAxis } from "../component/y-axis.js"
import { chart } from "../index.js"
import { renderDot } from "../shape/dot.js"
-import { resolveChartDimensions } from "../utils/chart-dimensions.js"
-import { getTransformedData, isMultiSeries } from "../utils/data-utils.js"
-import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js"
+import {
+ createBandCenterScale,
+ getResolvedEntity,
+ inferSeriesDataKey,
+ resolveCategoryLabel,
+} from "../utils/cartesian-helpers.js"
+import { createCartesianRenderer } from "../utils/cartesian-renderer.js"
+import { PALETTE_DEFAULT } from "../utils/constants.js"
+import { getTransformedData } from "../utils/data-utils.js"
import { generateLinePath } from "../utils/paths.js"
-import { processDeclarativeChild } from "../utils/process-declarative-child.js"
+import { ensureChartRuntimeId } from "../utils/runtime-id.js"
import { getFilteredData } from "../utils/scales.js"
-import { createSharedContext } from "../utils/shared-context.js"
-import {
- createTooltipHandlers,
- createTooltipMoveHandler,
-} from "../utils/tooltip-handlers.js"
+import { createTooltipHandlers } from "../utils/tooltip-handlers.js"
+import { renderComposedChart } from "./composed.js"
export const line = {
- /**
- * Config-based rendering entry point.
- * Builds default composition children from entity options and delegates to
- * `renderLineChart`.
- * @param {import('../types/charts').ChartEntity} entity
- * @param {import('@inglorious/web').Api} api
- * @returns {import('lit-html').TemplateResult}
- */
- render(entity, api) {
- const type = api.getType(entity.type)
-
- // Transform multi-series data to wide format if needed
- let transformedData = entity.data
- let dataKeys = []
-
- if (isMultiSeries(entity.data)) {
- // Extract dataKeys before transformation
- dataKeys = entity.data.map(
- (series, idx) =>
- series.dataKey || series.name || series.label || `series${idx}`,
- )
- // Transform to wide format for rendering
- transformedData = transformMultiSeriesToWide(entity.data)
- }
-
- // Apply data filtering if brush is enabled
- const entityData = entity.brush?.enabled
- ? getFilteredData({ ...entity, data: transformedData })
- : transformedData
-
- // Create entity with transformed and filtered data
- const entityWithData = {
- ...entity,
- data: entityData,
- dataKey:
- entity.dataKey || (transformedData[0]?.x !== undefined ? "x" : "name"),
- }
-
- // Convert entity config to declarative children
- const children = buildChildrenFromConfig(entityWithData, dataKeys)
-
- // Extract dataKeys for config if not already extracted
- if (dataKeys.length === 0) {
- if (entityWithData.data && entityWithData.data.length > 0) {
- const first = entityWithData.data[0]
- dataKeys = Object.keys(first).filter(
- (key) =>
- !["name", "x", "date"].includes(key) &&
- typeof first[key] === "number",
- )
- if (dataKeys.length === 0) {
- dataKeys = ["y", "value"].filter((k) => first[k] !== undefined)
- }
- if (dataKeys.length === 0) {
- dataKeys = ["value"]
- }
- }
- }
-
- // Use the unified motor (renderLineChart)
- // Pass original entity in config so brush can access unfiltered data
- return type.renderLineChart(
+ render: createCartesianRenderer({
+ seriesType: "line",
+ chartApi: () => chart,
+ toDisplayData: ({ entity, transformedData }) =>
+ entity.brush?.enabled
+ ? getFilteredData({ ...entity, data: transformedData })
+ : transformedData,
+ buildRenderConfig: ({
+ entity,
entityWithData,
- {
- width: entityWithData.width,
- height: entityWithData.height,
- dataKeys,
- originalEntity: { ...entity, data: transformedData },
- children,
- },
- api,
- )
- },
+ transformedData,
+ dataKeys,
+ }) => ({
+ width: entityWithData.width,
+ height: entityWithData.height,
+ dataKeys,
+ originalEntity: { ...entity, data: transformedData },
+ }),
+ }),
/**
* Composition rendering entry point for line charts.
@@ -103,134 +52,8 @@ export const line = {
* @param {import('@inglorious/web').Api} api
* @returns {import('lit-html').TemplateResult}
*/
- renderLineChart(entity, params = {}, api) {
- if (!entity) return svg`Entity not found`
- const { children, ...config } = params
-
- const entityForBrush = config.originalEntity || entity
- const entityWithData = { ...entity }
-
- // Collect data keys (used for scales and legends)
- const dataKeysSet = new Set()
- if (config.dataKeys && Array.isArray(config.dataKeys)) {
- config.dataKeys.forEach((key) => dataKeysSet.add(key))
- } else if (children) {
- const autoDataKeys = extractDataKeysFromChildren(children)
- autoDataKeys.forEach((key) => dataKeysSet.add(key))
- }
-
- const { width, height, padding } = resolveChartDimensions({
- configWidth: config.width,
- configHeight: config.height,
- configPadding: config.padding,
- entityWidth: entity.width,
- entityHeight: entity.height,
- entityPadding: entity.padding,
- })
-
- const context = createSharedContext(
- entityForBrush,
- {
- width,
- height,
- padding,
- usedDataKeys: dataKeysSet,
- chartType: "line",
- filteredEntity: entityWithData,
- },
- api,
- )
-
- // Adjust domain for Brush
- const brush = entityForBrush.brush
- if (brush?.enabled && brush.startIndex !== undefined) {
- if (config.originalEntity) {
- context.xScale.domain([0, entity.data.length - 1])
- } else {
- context.xScale.domain([brush.startIndex, brush.endIndex])
- }
- }
-
- context.dimensions = { width, height, padding }
- context.entity = entityWithData
- context.fullEntity = entityForBrush
- context.api = api
-
- // Process children (Grid, Line, XAxis, etc)
- const processedChildrenArray = (
- Array.isArray(children) ? children : [children]
- )
- .filter(Boolean)
- .map((child) => {
- const targetEntity =
- child && child.type === "Brush" ? entityForBrush : entityWithData
- return processDeclarativeChild(child, targetEntity, "line", api)
- })
- .filter(Boolean)
-
- const cat = {
- grid: [],
- axes: [],
- lines: [],
- dots: [],
- tooltip: [],
- legend: [],
- brush: [],
- others: [],
- }
- for (const child of processedChildrenArray) {
- if (typeof child === "function") {
- if (child.isGrid) cat.grid.push(child)
- else if (child.isAxis) cat.axes.push(child)
- else if (child.isLine) cat.lines.push(child)
- else if (child.isDots) cat.dots.push(child)
- else if (child.isTooltip) cat.tooltip.push(child)
- else if (child.isLegend) cat.legend.push(child)
- else if (child.isBrush) cat.brush.push(child)
- else cat.others.push(child)
- } else cat.others.push(child)
- }
-
- const processedChildren = [
- ...cat.grid,
- ...cat.lines,
- ...cat.axes,
- ...cat.dots,
- ...cat.tooltip,
- ...cat.legend,
- ...cat.brush,
- ...cat.others,
- ].map((child) => (typeof child === "function" ? child(context) : child))
-
- return html`
-
-
- ${renderTooltip(entityWithData, {}, api)}
-
- `
+ renderLineChart(entity, { children, config = {} }, api) {
+ return renderComposedChart(entity, { children, config }, api)
},
/**
@@ -347,20 +170,50 @@ export const line = {
*/
renderLine(entity, { config = {} }, api) {
const lineFn = (ctx) => {
- const { xScale, yScale, entity: e } = ctx
+ const { xScale, yScale } = ctx
+ const e = getResolvedEntity(ctx, entity)
const {
dataKey,
- stroke = "#8884d8",
+ stroke = PALETTE_DEFAULT[0],
type = "linear",
showDots = false,
} = config
- const data = getTransformedData(e, dataKey)
- const chartData = data.map((d, i) => ({ ...d, x: i }))
+ const resolvedDataKey =
+ dataKey ??
+ (Array.isArray(config.data)
+ ? inferSeriesDataKey(config.data, "line")
+ : undefined)
+ const dataEntity = Array.isArray(config.data)
+ ? { ...e, data: config.data }
+ : e
+ const plotEntity = ctx.fullEntity || dataEntity
+ const indexOffset = ctx.indexOffset ?? 0
+ const indexEnd = ctx.indexEnd
+ const data = getTransformedData(dataEntity, resolvedDataKey)
+ const scaleForSeries = xScale.bandwidth
+ ? createBandCenterScale(xScale)
+ : xScale
+ const chartData = data.map((d, i) => ({
+ ...d,
+ x: xScale.bandwidth
+ ? resolveCategoryLabel(plotEntity, i + indexOffset)
+ : i + indexOffset,
+ }))
+
+ const clippedChartData =
+ typeof indexEnd === "number"
+ ? chartData.filter((point) => point.x <= indexEnd)
+ : chartData
- const path = generateLinePath(chartData, xScale, yScale, type)
+ const path = generateLinePath(
+ clippedChartData,
+ scaleForSeries,
+ yScale,
+ type,
+ )
if (!path || path.includes("NaN")) return svg``
return svg`
-
+
${showDots ? line.renderDots(e, { config: { ...config, fill: stroke } }, api)(ctx) : ""}
`
@@ -378,18 +231,34 @@ export const line = {
*/
renderDots(entity, { config = {} }, api) {
const dotsFn = (ctx) => {
- const { xScale, yScale, entity: e } = ctx
- const { dataKey, fill = "#8884d8", r = "0.25em" } = config
- const data = getTransformedData(e, dataKey)
+ const { xScale, yScale } = ctx
+ const e = getResolvedEntity(ctx, entity)
+ const { dataKey, fill = PALETTE_DEFAULT[0], r = "0.25em" } = config
+ const resolvedDataKey =
+ dataKey ??
+ (Array.isArray(config.data)
+ ? inferSeriesDataKey(config.data, "line")
+ : undefined)
+ const dataEntity = Array.isArray(config.data)
+ ? { ...e, data: config.data }
+ : e
+ const plotEntity = ctx.fullEntity || dataEntity
+ const indexOffset = ctx.indexOffset ?? 0
+ const indexEnd = ctx.indexEnd
+ const data = getTransformedData(dataEntity, resolvedDataKey)
+ const scaleForSeries = xScale.bandwidth
+ ? createBandCenterScale(xScale)
+ : xScale
if (!data || data.length === 0) return svg``
return svg`
-
+
${repeat(
data,
- (d, i) => `${dataKey}-${i}`,
+ (d, i) => `${resolvedDataKey || "value"}-${i}`,
(d, i) => {
- const originalDataPoint = e.data?.[i]
+ const resolvedIndex = i + indexOffset
+ const originalDataPoint = plotEntity.data?.[resolvedIndex]
const label =
originalDataPoint?.name ??
originalDataPoint?.label ??
@@ -401,9 +270,22 @@ export const line = {
entity: e,
api: ctx.api || api,
tooltipData: { label: String(label), value: d.y, color: fill },
+ enabled:
+ config.showTooltip ??
+ (ctx.tooltipMode
+ ? ctx.tooltipMode === "all"
+ : ctx.tooltipEnabled),
})
+ if (typeof indexEnd === "number" && resolvedIndex > indexEnd) {
+ return svg``
+ }
+
return renderDot({
- cx: xScale(i),
+ cx: scaleForSeries(
+ xScale.bandwidth
+ ? resolveCategoryLabel(plotEntity, resolvedIndex)
+ : resolvedIndex,
+ ),
cy: yScale(d.y),
r,
fill,
@@ -430,7 +312,7 @@ export const line = {
const { dataKeys = [], labels = [], colors = [] } = config
const series = dataKeys.map((key, i) => ({
name: labels[i] || key,
- color: colors[i % colors.length] || "#8884d8",
+ color: colors[i % colors.length] || PALETTE_DEFAULT[0],
}))
return renderLegend(entity, { series, ...ctx.dimensions }, api)
}
@@ -451,134 +333,13 @@ export const line = {
renderBrush: createBrushComponent(),
}
-/**
- * Builds declarative children from entity config for renderChart (config style)
- * Converts entity configuration into children objects that renderLineChart can process
- * @param {import('../types/charts').ChartEntity} entity
- * @param {string[]} [providedDataKeys] - Optional dataKeys if already extracted (e.g., from multi-series before transformation)
- */
-function buildChildrenFromConfig(entity, providedDataKeys = null) {
- const children = []
-
- // Grid
- if (entity.showGrid !== false) {
- children.push(
- chart.CartesianGrid({ stroke: "#eee", strokeDasharray: "5 5" }),
- )
- }
-
- // XAxis - determine dataKey from entity or data structure
- let xAxisDataKey = entity.dataKey
- if (!xAxisDataKey && entity.data && entity.data.length > 0) {
- const firstItem = entity.data[0]
- xAxisDataKey = firstItem.name || firstItem.x || firstItem.date || "name"
- }
- if (!xAxisDataKey) {
- xAxisDataKey = "name"
- }
- children.push(chart.XAxis({ dataKey: xAxisDataKey }))
-
- // YAxis
- children.push(chart.YAxis({ width: "auto" }))
-
- // Extract dataKeys from entity data or use provided ones
- let dataKeys = providedDataKeys
- if (!dataKeys || dataKeys.length === 0) {
- if (isMultiSeries(entity.data)) {
- // Multi-series: use series names as dataKeys
- dataKeys = entity.data.map((series, idx) => {
- return series.dataKey || series.name || series.label || `series${idx}`
- })
- } else if (entity.data && entity.data.length > 0) {
- // Wide format: extract numeric keys
- const first = entity.data[0]
- dataKeys = Object.keys(first).filter(
- (key) =>
- !["name", "x", "date"].includes(key) &&
- typeof first[key] === "number",
- )
- if (dataKeys.length === 0) {
- dataKeys = ["y", "value"].filter((k) => first[k] !== undefined)
- }
- if (dataKeys.length === 0) {
- dataKeys = ["value"] // Default fallback
- }
- } else {
- dataKeys = ["value"] // Default fallback
- }
- }
-
- // Lines (one per dataKey)
- const colors = entity.colors || ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"]
- dataKeys.forEach((dataKey, index) => {
- children.push(
- chart.Line({
- dataKey,
- stroke: colors[index % colors.length],
- showDots: entity.showPoints !== false,
- }),
- )
- })
-
- // Dots (if showPoints is true)
- if (entity.showPoints !== false && dataKeys.length > 0) {
- dataKeys.forEach((dataKey, index) => {
- children.push(
- chart.Dots({
- dataKey,
- fill: colors[index % colors.length],
- }),
- )
- })
- }
-
- // Tooltip
- if (entity.showTooltip !== false) {
- children.push(chart.Tooltip({}))
- }
-
- // Legend
- if (entity.showLegend && isMultiSeries(entity.data)) {
- children.push(
- chart.Legend({
- dataKeys,
- labels: entity.labels || dataKeys,
- colors: entity.colors,
- }),
- )
- }
-
- // Brush
- if (entity.brush?.enabled && entity.brush?.visible !== false) {
- children.push(
- chart.Brush({
- dataKey: xAxisDataKey,
- height: entity.brush.height || 30,
- }),
- )
- }
-
- return children
+function ensureClipPathId(entity) {
+ return `chart-clip-${ensureChartRuntimeId(entity)}`
}
-function transformMultiSeriesToWide(multiSeriesData) {
- if (!isMultiSeries(multiSeriesData)) return null
- const dataMap = new Map()
- const seriesKeys = multiSeriesData.map((s) => s.dataKey || s.name || "series")
-
- multiSeriesData.forEach((series, seriesIdx) => {
- const key = seriesKeys[seriesIdx]
- const values = Array.isArray(series.values) ? series.values : [series]
- values.forEach((point, index) => {
- const xVal = point?.x ?? index
- if (!dataMap.has(xVal)) dataMap.set(xVal, { x: xVal, name: String(xVal) })
- dataMap.get(xVal)[key] = point?.y ?? point?.value ?? 0
- })
- })
-
- return Array.from(dataMap.values()).sort((a, b) => {
- return typeof a.x === "number"
- ? a.x - b.x
- : String(a.x).localeCompare(String(b.x))
- })
+function resolveClipPathId(ctx, entity) {
+ if (ctx.clipPathId) return ctx.clipPathId
+ const clipPathId = ensureClipPathId(ctx.fullEntity || entity)
+ ctx.clipPathId = clipPathId
+ return clipPathId
}
diff --git a/packages/charts/src/cartesian/line.test.js b/packages/charts/src/cartesian/line.test.js
index 0d09f9ed..e75f0e91 100644
--- a/packages/charts/src/cartesian/line.test.js
+++ b/packages/charts/src/cartesian/line.test.js
@@ -111,9 +111,11 @@ describe("line", () => {
entity,
{
children,
- width: 800,
- height: 400,
- padding: { top: 0, right: 0, bottom: 0, left: 0 },
+ config: {
+ width: 800,
+ height: 400,
+ padding: { top: 0, right: 0, bottom: 0, left: 0 },
+ },
},
api,
)
diff --git a/packages/charts/src/chart.test.js b/packages/charts/src/chart.test.js
index 6bbc36c1..9a7ee347 100644
--- a/packages/charts/src/chart.test.js
+++ b/packages/charts/src/chart.test.js
@@ -4,6 +4,7 @@
import { describe, expect, it, vi } from "vitest"
import { chart } from "./index.js"
+import { render as renderTemplate } from "./template.js"
import {
areaChart,
barChart,
@@ -258,7 +259,7 @@ describe("chart", () => {
getType: vi.fn(() => barChart),
}
- const result = chart.render(entity, mockApi)
+ const result = renderTemplate(entity, mockApi)
expect(mockApi.getType).toHaveBeenCalledWith("bar")
expect(result).toBeDefined()
@@ -276,7 +277,7 @@ describe("chart", () => {
getType: vi.fn(() => null),
}
- const result = chart.render(entity, mockApi)
+ const result = renderTemplate(entity, mockApi)
expect(mockApi.getType).toHaveBeenCalledWith("unknown")
expect(result).toBeDefined()
diff --git a/packages/charts/src/component/brush.js b/packages/charts/src/component/brush.js
index 9855d879..1527aa92 100644
--- a/packages/charts/src/component/brush.js
+++ b/packages/charts/src/component/brush.js
@@ -1,6 +1,7 @@
/* eslint-disable no-magic-numbers */
import { svg } from "@inglorious/web"
+import { getResolvedEntity } from "../utils/cartesian-helpers.js"
import { isValidNumber } from "../utils/data-utils.js"
import { createXScale } from "../utils/scales.js"
@@ -237,7 +238,7 @@ export function renderBrush(entity, props, api) {
export function createBrushComponent(defaultConfig = {}) {
return (entity, props, api) => {
const brushFn = (ctx) => {
- const entityFromContext = ctx.fullEntity || ctx.entity || entity
+ const entityFromContext = ctx.fullEntity || getResolvedEntity(ctx, entity)
const config = { ...defaultConfig, ...(props.config || {}) }
const result = renderBrush(
diff --git a/packages/charts/src/component/grid.js b/packages/charts/src/component/grid.js
index 75b438d9..cfe3a422 100644
--- a/packages/charts/src/component/grid.js
+++ b/packages/charts/src/component/grid.js
@@ -22,7 +22,23 @@ import { calculateXTicks } from "../utils/scales.js"
*/
// eslint-disable-next-line no-unused-vars
export function renderGrid(entity, props, api) {
- const { xScale, yScale, width, height, padding, customYTicks } = props
+ const {
+ xScale,
+ yScale,
+ width,
+ height,
+ padding,
+ customYTicks,
+ stroke = "#eee",
+ strokeDasharray = "5 5",
+ strokeWidth = 1,
+ } = props
+
+ // If grid is visually disabled, bail early.
+ if (strokeWidth <= 0 || stroke === "none" || stroke === "transparent") {
+ return svg``
+ }
+
// Use entity.data if available, otherwise fallback to scale ticks
const data = entity?.data
@@ -56,6 +72,7 @@ export function renderGrid(entity, props, api) {
const yTicks =
customYTicks && Array.isArray(customYTicks) ? customYTicks : yScale.ticks(5)
+ // Render main grid groups.
return svg`
@@ -66,8 +83,9 @@ export function renderGrid(entity, props, api) {
const y = yScale(t)
return svg`
{
const tooltipFn = (ctx) => {
- const entityFromContext = ctx.entity || entity
+ const entityFromContext = getResolvedEntity(ctx, entity)
return renderTooltip(entityFromContext, {}, api)
}
// Mark as tooltip component for stable identification during processing
diff --git a/packages/charts/src/core/chart-core.js b/packages/charts/src/core/chart-core.js
new file mode 100644
index 00000000..df59a6bd
--- /dev/null
+++ b/packages/charts/src/core/chart-core.js
@@ -0,0 +1,62 @@
+import { area } from "../cartesian/area.js"
+import { bar } from "../cartesian/bar.js"
+import { composed } from "../cartesian/composed.js"
+import { line } from "../cartesian/line.js"
+import * as handlers from "../handlers.js"
+import { donut } from "../polar/donut.js"
+import { pie } from "../polar/pie.js"
+
+const NOOP = () => {}
+
+/**
+ * Core chart renderers (store-agnostic).
+ * These renderers accept plain props and do not require an external store api.
+ * Useful as an initial step toward a pure presentational charts layer.
+ */
+export const coreCharts = {
+ line: buildPureChart("line", line),
+ area: buildPureChart("area", area),
+ bar: buildPureChart("bar", bar),
+ composed: buildPureChart("composed", composed),
+ pie: buildPureChart("pie", pie),
+ donut: buildPureChart("donut", donut),
+}
+
+function buildPureChart(typeName, typeRenderer) {
+ const chartRuntime = buildChartRuntime(typeName, typeRenderer)
+ const draw = (props = {}) => {
+ const chartModel = buildChartModel(typeName, props)
+ return typeRenderer.render(chartModel, chartRuntime)
+ }
+
+ return {
+ /**
+ * Render a chart from plain props (no store required).
+ * @param {Record} props
+ */
+ render: draw,
+ draw,
+ }
+}
+
+function buildChartRuntime(typeName, typeRenderer) {
+ const registry = { [typeName]: typeRenderer }
+ return {
+ getType(name) {
+ return registry[name]
+ },
+ notify: NOOP,
+ }
+}
+
+function buildChartModel(typeName, props) {
+ const chartModel = {
+ id: props.id || `core-${typeName}`,
+ type: typeName,
+ ...props,
+ }
+
+ // Reuse default chart initialization without requiring store wiring.
+ handlers.create(chartModel)
+ return chartModel
+}
diff --git a/packages/charts/src/core/chart-type-methods.js b/packages/charts/src/core/chart-type-methods.js
new file mode 100644
index 00000000..fb881029
--- /dev/null
+++ b/packages/charts/src/core/chart-type-methods.js
@@ -0,0 +1,6 @@
+export const CHART_TYPE_METHODS = [
+ { type: "line", suffix: "Line" },
+ { type: "area", suffix: "Area" },
+ { type: "bar", suffix: "Bar" },
+ { type: "pie", suffix: "Pie" },
+]
diff --git a/packages/charts/src/core/create-chart-instance.js b/packages/charts/src/core/create-chart-instance.js
new file mode 100644
index 00000000..aed838fb
--- /dev/null
+++ b/packages/charts/src/core/create-chart-instance.js
@@ -0,0 +1,267 @@
+import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js"
+import { CHART_TYPE_METHODS } from "./chart-type-methods.js"
+import {
+ attachInstancePascalAliases,
+ createDeclarativeChildren,
+ createInstanceRenderAliases,
+} from "./declarative-children.js"
+import { createTypeDispatcher } from "./render-dispatch.js"
+
+const INLINE_PROTECTED_PROPS = [
+ "showTooltip",
+ "width",
+ "height",
+ "type",
+ "data",
+]
+const warnedLegacyMethods = new Set()
+
+export function createChartInstance(entity, api, isInline = false) {
+ let currentEntity = entity
+ const dispatchByEntityType = createTypeDispatcher(api)
+
+ const readCurrentEntity = () => currentEntity
+ const writeCurrentEntity = (nextEntity) => {
+ currentEntity = nextEntity
+ }
+
+ const buildRenderer = isInline
+ ? createSelfManagedRenderer({
+ readCurrentEntity,
+ writeCurrentEntity,
+ dispatchByEntityType,
+ })
+ : createStoreManagedRenderer({ readCurrentEntity, dispatchByEntityType })
+ const legacyAdapter = wrapAsLegacyAdapter(buildRenderer)
+
+ const declarativeChildren = createDeclarativeChildren()
+ const standardMethods = mapCatalogToMethods(buildRenderer, "standard")
+ const legacyMethods = mapCatalogToMethods(legacyAdapter, "legacy")
+
+ const instance = {
+ ...standardMethods,
+
+ ...declarativeChildren,
+
+ ...legacyMethods,
+
+ ...createInstanceRenderAliases(declarativeChildren),
+ }
+
+ return attachInstancePascalAliases(instance)
+}
+
+export function createInlineChartInstance(api, tempEntity, initializeEntity) {
+ const entity = tempEntity || {
+ id: `__temp_${Date.now()}`,
+ type: "line",
+ data: [],
+ }
+
+ const preserved = pickDefinedProps(entity, INLINE_PROTECTED_PROPS)
+
+ initializeEntity(entity)
+ Object.assign(entity, preserved)
+
+ return createChartInstance(entity, api, true)
+}
+
+function mapCatalogToMethods(buildRenderMethod, mode) {
+ return Object.fromEntries(
+ CHART_TYPE_METHODS.map(({ type, suffix }) => {
+ const methodName = `render${suffix}Chart`
+ const exposedName = mode === "standard" ? `${suffix}Chart` : methodName
+ return [exposedName, buildRenderMethod(type, methodName)]
+ }),
+ )
+}
+
+function createStoreManagedRenderer({
+ readCurrentEntity,
+ dispatchByEntityType,
+}) {
+ return (chartType, renderMethod) =>
+ (config = {}, children = []) => {
+ const normalizedArgs = normalizeRenderInputs({
+ chartType,
+ config,
+ children,
+ })
+ const currentEntity = readCurrentEntity()
+ const finalConfig = buildFinalConfig({
+ chartType,
+ config: normalizedArgs.config,
+ children: normalizedArgs.children,
+ dataFromEntity: currentEntity.data,
+ shouldFallbackToEntityData: true,
+ })
+
+ return dispatchRender({
+ entity: currentEntity,
+ renderMethod,
+ children: normalizedArgs.children,
+ finalConfig,
+ dispatchByEntityType,
+ })
+ }
+}
+
+function createSelfManagedRenderer({
+ readCurrentEntity,
+ writeCurrentEntity,
+ dispatchByEntityType,
+}) {
+ return (chartType, renderMethod) =>
+ (config = {}, children = []) => {
+ const normalizedArgs = normalizeRenderInputs({
+ chartType,
+ config,
+ children,
+ })
+ const currentEntity = readCurrentEntity()
+ const nextEntity = buildInlineEntity(
+ currentEntity,
+ chartType,
+ normalizedArgs.config,
+ )
+ writeCurrentEntity(nextEntity)
+
+ const finalConfig = buildFinalConfig({
+ chartType,
+ config: normalizedArgs.config,
+ children: normalizedArgs.children,
+ dataFromEntity: nextEntity.data,
+ shouldFallbackToEntityData: false,
+ })
+
+ return dispatchRender({
+ entity: nextEntity,
+ renderMethod,
+ children: normalizedArgs.children,
+ finalConfig,
+ dispatchByEntityType,
+ })
+ }
+}
+
+function wrapAsLegacyAdapter(buildStandardMethod) {
+ return (chartType, renderMethod) =>
+ (firstArg = {}, secondArg) => {
+ if (isLegacyRenderArgs(firstArg, secondArg)) {
+ warnLegacySignature(renderMethod)
+ return buildStandardMethod(chartType, renderMethod)(secondArg, firstArg)
+ }
+
+ if (Array.isArray(firstArg) && secondArg === undefined) {
+ return buildStandardMethod(chartType, renderMethod)({}, firstArg)
+ }
+
+ return buildStandardMethod(chartType, renderMethod)(firstArg, secondArg)
+ }
+}
+
+function buildInlineEntity(currentEntity, chartType, config) {
+ const resolvedData = config.data ?? currentEntity.data
+ return {
+ ...currentEntity,
+ type: config.type || chartType,
+ ...(resolvedData ? { data: resolvedData } : null),
+ width: config.width || currentEntity.width,
+ height: config.height || currentEntity.height,
+ }
+}
+
+function buildFinalConfig({
+ chartType,
+ config,
+ children,
+ dataFromEntity,
+ shouldFallbackToEntityData,
+}) {
+ return {
+ ...config,
+ data:
+ config.data || (shouldFallbackToEntityData ? dataFromEntity : undefined),
+ dataKeys:
+ chartType !== "pie"
+ ? config.dataKeys || extractDataKeysFromChildren(children)
+ : undefined,
+ }
+}
+
+function dispatchRender({
+ entity,
+ renderMethod,
+ children,
+ finalConfig,
+ dispatchByEntityType,
+}) {
+ return dispatchByEntityType(entity, renderMethod, {
+ children: Array.isArray(children) ? children : [children],
+ config: finalConfig,
+ })
+}
+
+function pickDefinedProps(source, props) {
+ return props.reduce((acc, prop) => {
+ if (source[prop] !== undefined) acc[prop] = source[prop]
+ return acc
+ }, {})
+}
+
+function warnLegacySignature(renderMethod) {
+ if (!isDevelopmentEnvironment()) return
+ if (warnedLegacyMethods.has(renderMethod)) return
+
+ warnedLegacyMethods.add(renderMethod)
+ globalThis.console?.warn?.(
+ `[charts] ${renderMethod}(children, config) is deprecated. ` +
+ `Use ${toStandardMethodName(renderMethod)}(config, children).`,
+ )
+}
+
+function toStandardMethodName(renderMethod) {
+ return renderMethod.replace(/^render/, "")
+}
+
+function isDevelopmentEnvironment() {
+ const nodeEnv = globalThis?.process?.env?.NODE_ENV
+ if (typeof nodeEnv === "string") {
+ return nodeEnv !== "production"
+ }
+ return true
+}
+
+function isLegacyRenderArgs(firstArg, secondArg) {
+ return Array.isArray(firstArg) && isPlainObject(secondArg)
+}
+
+function isPlainObject(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+}
+
+function normalizeRenderInputs({ chartType, config, children }) {
+ const safeConfig = isPlainObject(config) ? { ...config } : {}
+ const safeChildren = normalizeChildren(children)
+
+ if (safeConfig.data !== undefined && !Array.isArray(safeConfig.data)) {
+ delete safeConfig.data
+ }
+
+ if (
+ chartType !== "pie" &&
+ safeConfig.dataKeys !== undefined &&
+ !Array.isArray(safeConfig.dataKeys)
+ ) {
+ delete safeConfig.dataKeys
+ }
+
+ return { config: safeConfig, children: safeChildren }
+}
+
+function normalizeChildren(children) {
+ if (children === undefined || children === null) return []
+ if (Array.isArray(children)) return children
+
+ return [children]
+}
diff --git a/packages/charts/src/core/create-chart-instance.test.js b/packages/charts/src/core/create-chart-instance.test.js
new file mode 100644
index 00000000..335efb8d
--- /dev/null
+++ b/packages/charts/src/core/create-chart-instance.test.js
@@ -0,0 +1,173 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+beforeEach(() => {
+ vi.resetModules()
+})
+
+describe("createChartInstance legacy adapter", () => {
+ it("warns once per render method when legacy signature is used", async () => {
+ const warnSpy = vi
+ .spyOn(globalThis.console, "warn")
+ .mockImplementation(() => {})
+
+ const { createChartInstance } = await import("./create-chart-instance.js")
+
+ const renderLineChart = vi.fn(() => "ok")
+ const api = {
+ getType: () => ({ renderLineChart }),
+ }
+ const entity = {
+ id: "line-1",
+ type: "line",
+ data: [{ name: "A", value: 10 }],
+ }
+
+ const instance = createChartInstance(entity, api)
+
+ instance.renderLineChart([], { width: 400 })
+ instance.renderLineChart([], { width: 500 })
+
+ const chartsWarnings = warnSpy.mock.calls
+ .map(([message]) => message)
+ .filter(
+ (message) =>
+ typeof message === "string" &&
+ message.includes("[charts]") &&
+ message.includes("is deprecated"),
+ )
+
+ expect(chartsWarnings).toHaveLength(1)
+ expect(chartsWarnings[0]).toContain(
+ "renderLineChart(children, config) is deprecated",
+ )
+ expect(renderLineChart).toHaveBeenCalledTimes(2)
+
+ warnSpy.mockRestore()
+ })
+
+ it("does not warn when standard signature is used", async () => {
+ const warnSpy = vi
+ .spyOn(globalThis.console, "warn")
+ .mockImplementation(() => {})
+
+ const { createChartInstance } = await import("./create-chart-instance.js")
+
+ const renderLineChart = vi.fn(() => "ok")
+ const api = {
+ getType: () => ({ renderLineChart }),
+ }
+ const entity = {
+ id: "line-2",
+ type: "line",
+ data: [{ name: "A", value: 10 }],
+ }
+
+ const instance = createChartInstance(entity, api)
+ instance.LineChart({ width: 640 }, [])
+
+ const chartsWarnings = warnSpy.mock.calls
+ .map(([message]) => message)
+ .filter(
+ (message) =>
+ typeof message === "string" &&
+ message.includes("[charts]") &&
+ message.includes("is deprecated"),
+ )
+
+ expect(chartsWarnings).toHaveLength(0)
+ expect(renderLineChart).toHaveBeenCalledTimes(1)
+
+ warnSpy.mockRestore()
+ })
+
+ it("accepts renderLineChart(children) without warning", async () => {
+ const warnSpy = vi
+ .spyOn(globalThis.console, "warn")
+ .mockImplementation(() => {})
+
+ const { createChartInstance } = await import("./create-chart-instance.js")
+
+ const renderLineChart = vi.fn(() => "ok")
+ const api = {
+ getType: () => ({ renderLineChart }),
+ }
+ const entity = {
+ id: "line-3",
+ type: "line",
+ data: [{ name: "A", value: 10 }],
+ }
+
+ const instance = createChartInstance(entity, api)
+ const lineChild = () => null
+ lineChild.dataKey = "value"
+ const children = [lineChild]
+ instance.renderLineChart(children)
+
+ const chartsWarnings = warnSpy.mock.calls
+ .map(([message]) => message)
+ .filter(
+ (message) =>
+ typeof message === "string" &&
+ message.includes("[charts]") &&
+ message.includes("is deprecated"),
+ )
+
+ expect(chartsWarnings).toHaveLength(0)
+ expect(renderLineChart).toHaveBeenCalledTimes(1)
+ expect(renderLineChart.mock.calls[0][1].children).toEqual(children)
+ expect(renderLineChart.mock.calls[0][1].config).toMatchObject({
+ data: entity.data,
+ dataKeys: ["value"],
+ })
+
+ warnSpy.mockRestore()
+ })
+
+ it("normalizes invalid config fields in dev mode", async () => {
+ const { createChartInstance } = await import("./create-chart-instance.js")
+
+ const renderLineChart = vi.fn(() => "ok")
+ const api = {
+ getType: () => ({ renderLineChart }),
+ }
+ const entity = {
+ id: "line-4",
+ type: "line",
+ data: [{ name: "A", value: 10 }],
+ }
+
+ const instance = createChartInstance(entity, api)
+ const lineChild = () => null
+ lineChild.dataKey = "value"
+ const children = [lineChild]
+
+ instance.LineChart({ data: "invalid", dataKeys: "invalid" }, children)
+
+ expect(renderLineChart).toHaveBeenCalledTimes(1)
+ expect(renderLineChart.mock.calls[0][1].config.data).toEqual(entity.data)
+ expect(renderLineChart.mock.calls[0][1].config.dataKeys).toEqual(["value"])
+ })
+
+ it("accepts non-object config and null children with safe fallback", async () => {
+ const { createChartInstance } = await import("./create-chart-instance.js")
+
+ const renderLineChart = vi.fn(() => "ok")
+ const api = {
+ getType: () => ({ renderLineChart }),
+ }
+ const entity = {
+ id: "line-5",
+ type: "line",
+ data: [{ name: "A", value: 10 }],
+ }
+
+ const instance = createChartInstance(entity, api)
+ instance.LineChart("invalid", null)
+
+ expect(renderLineChart).toHaveBeenCalledTimes(1)
+ expect(renderLineChart.mock.calls[0][1]).toMatchObject({
+ children: [],
+ config: { data: entity.data, dataKeys: [] },
+ })
+ })
+})
diff --git a/packages/charts/src/core/declarative-children.js b/packages/charts/src/core/declarative-children.js
new file mode 100644
index 00000000..06ab2b2d
--- /dev/null
+++ b/packages/charts/src/core/declarative-children.js
@@ -0,0 +1,45 @@
+export const DECLARATIVE_CHILD_NAMES = [
+ "XAxis",
+ "YAxis",
+ "Line",
+ "Area",
+ "Bar",
+ "Pie",
+ "CartesianGrid",
+ "Tooltip",
+ "Brush",
+ "Dots",
+ "Legend",
+]
+
+export function createDeclarativeChildren() {
+ return Object.fromEntries(
+ DECLARATIVE_CHILD_NAMES.map((name) => [name, buildDeclarativeChild(name)]),
+ )
+}
+
+export function createInstanceRenderAliases(declarativeChildren) {
+ return {
+ renderCartesianGrid: declarativeChildren.CartesianGrid,
+ renderXAxis: declarativeChildren.XAxis,
+ renderYAxis: declarativeChildren.YAxis,
+ renderLine: declarativeChildren.Line,
+ renderArea: declarativeChildren.Area,
+ renderBar: declarativeChildren.Bar,
+ renderPie: declarativeChildren.Pie,
+ renderTooltip: declarativeChildren.Tooltip,
+ renderBrush: declarativeChildren.Brush,
+ renderDots: declarativeChildren.Dots,
+ renderLegend: declarativeChildren.Legend,
+ }
+}
+
+export function attachInstancePascalAliases(instance) {
+ instance.Dots = instance.renderDots
+ instance.Legend = instance.renderLegend
+ return instance
+}
+
+function buildDeclarativeChild(typeName) {
+ return (config = {}) => ({ type: typeName, config })
+}
diff --git a/packages/charts/src/core/empty-instance.js b/packages/charts/src/core/empty-instance.js
new file mode 100644
index 00000000..64e835f3
--- /dev/null
+++ b/packages/charts/src/core/empty-instance.js
@@ -0,0 +1,52 @@
+import { CHART_TYPE_METHODS } from "./chart-type-methods.js"
+import { DECLARATIVE_CHILD_NAMES } from "./declarative-children.js"
+import { renderEmptyTemplate } from "./render-dispatch.js"
+
+export function getEmptyChartInstance() {
+ const chartMethods = buildNoopChartMethods()
+ const declarativeMethods = buildNoopDeclarativeMethods()
+ const compatibilityMethods = buildNoopCompatibilityMethods()
+
+ return {
+ Dots: renderEmptyTemplate,
+ Legend: renderEmptyTemplate,
+ renderChart: renderEmptyTemplate,
+ ...chartMethods,
+ ...compatibilityMethods,
+ ...declarativeMethods,
+ }
+}
+
+function buildNoopChartMethods() {
+ return Object.fromEntries(
+ CHART_TYPE_METHODS.flatMap(({ suffix }) => [
+ [`${suffix}Chart`, renderEmptyTemplate],
+ [`render${suffix}Chart`, renderEmptyTemplate],
+ ]),
+ )
+}
+
+function buildNoopDeclarativeMethods() {
+ return Object.fromEntries(
+ DECLARATIVE_CHILD_NAMES.map((methodName) => [
+ methodName,
+ renderEmptyTemplate,
+ ]),
+ )
+}
+
+function buildNoopCompatibilityMethods() {
+ return {
+ renderCartesianGrid: renderEmptyTemplate,
+ renderXAxis: renderEmptyTemplate,
+ renderYAxis: renderEmptyTemplate,
+ renderLine: renderEmptyTemplate,
+ renderArea: renderEmptyTemplate,
+ renderBar: renderEmptyTemplate,
+ renderPie: renderEmptyTemplate,
+ renderTooltip: renderEmptyTemplate,
+ renderBrush: renderEmptyTemplate,
+ renderDots: renderEmptyTemplate,
+ renderLegend: renderEmptyTemplate,
+ }
+}
diff --git a/packages/charts/src/core/render-dispatch.js b/packages/charts/src/core/render-dispatch.js
new file mode 100644
index 00000000..25ba0c04
--- /dev/null
+++ b/packages/charts/src/core/render-dispatch.js
@@ -0,0 +1,317 @@
+import { svg } from "@inglorious/web"
+
+import {
+ buildComposedChildren,
+ renderComposedChart,
+} from "../cartesian/composed.js"
+import * as handlers from "../handlers.js"
+import { ensureChartRuntimeIdWithKey } from "../utils/runtime-id.js"
+
+const FIRST_CHAR_INDEX = 0
+const REST_START_INDEX = 1
+const inlineEntityCache = new WeakMap()
+const inlineEntityKeyCache = new Map()
+
+export function renderByChartType(typeKey) {
+ const methodName = `render${capitalize(typeKey)}Chart`
+
+ return function renderUsingType(firstArg, secondArg, thirdArg) {
+ const { entity, params, api } = normalizeRenderByTypeArgs(
+ typeKey,
+ firstArg,
+ secondArg,
+ thirdArg,
+ )
+
+ if (!entity || !api) return renderEmptyTemplate()
+ const chartType = api.getType(typeKey)
+ return chartType?.[methodName]
+ ? chartType[methodName](entity, params, api)
+ : renderEmptyTemplate()
+ }
+}
+
+export function renderChart() {
+ return function renderGeneric(firstArg, secondArg, thirdArg) {
+ const { entity, params, api } = normalizeRenderChartArgs(
+ firstArg,
+ secondArg,
+ thirdArg,
+ )
+
+ if (!api) return renderEmptyTemplate()
+
+ const inferredType = inferChartType(entity, params)
+ if (!inferredType) return renderEmptyTemplate()
+
+ if (inferredType === "composed") {
+ const normalized = normalizeRenderByTypeArgs(
+ inferredType,
+ firstArg,
+ secondArg,
+ thirdArg,
+ )
+ const composedEntity = {
+ ...normalized.entity,
+ ...normalized.params.config,
+ }
+ return renderComposedChart(
+ composedEntity,
+ {
+ children: buildComposedChildren(composedEntity),
+ config: normalized.params.config,
+ },
+ api,
+ )
+ }
+
+ if (isCartesianType(inferredType)) {
+ const normalized = normalizeRenderByTypeArgs(
+ inferredType,
+ firstArg,
+ secondArg,
+ thirdArg,
+ )
+ return renderComposedChart(normalized.entity, normalized.params, api)
+ }
+
+ return renderByChartType(inferredType)(firstArg, secondArg, thirdArg)
+ }
+}
+
+export function buildComponentRenderer(
+ methodName,
+ typeOverride = null,
+ preferChartTypeProp = false,
+) {
+ return function renderComponent(entity, props = {}, api) {
+ if (!entity) return renderEmptyTemplate()
+ const { config = {}, chartType = null } = props
+ const resolvedTypeName =
+ typeOverride || (preferChartTypeProp ? chartType : null) || entity.type
+ const type = api.getType(resolvedTypeName)
+ return type?.[methodName]
+ ? type[methodName](entity, { config }, api)
+ : renderEmptyTemplate()
+ }
+}
+
+export function renderWithEntityTypeMethod(entity, methodName, params, api) {
+ const type = api.getType(entity.type)
+ return type?.[methodName]
+ ? type[methodName](entity, params, api)
+ : renderEmptyTemplate()
+}
+
+export function createTypeDispatcher(api) {
+ return function dispatchByEntityType(entity, methodName, params) {
+ return renderWithEntityTypeMethod(entity, methodName, params, api)
+ }
+}
+
+export function renderEmptyTemplate() {
+ return svg``
+}
+
+function capitalize(value) {
+ return value
+ ? value[FIRST_CHAR_INDEX].toUpperCase() + value.slice(REST_START_INDEX)
+ : ""
+}
+
+function normalizeRenderByTypeArgs(typeKey, firstArg, secondArg, thirdArg) {
+ let entity = null
+ let params = {}
+ let api = null
+
+ if (isApiLike(secondArg) && thirdArg === undefined) {
+ api = secondArg
+ params = normalizeChartParams(firstArg)
+ } else {
+ entity = firstArg
+ params = normalizeChartParams(secondArg)
+ api = thirdArg
+ }
+
+ if (!api) {
+ return { entity: null, params, api: null }
+ }
+
+ if (!entity) {
+ entity = getInlineEntity(typeKey, params.config)
+ }
+
+ hydrateEntityFromConfig(typeKey, entity, params.config)
+
+ if (api.getEntity) {
+ const existingEntity = entity?.id ? api.getEntity(entity.id) : undefined
+ if (existingEntity) {
+ if (params.config?.data !== undefined) {
+ entity = { ...existingEntity, data: params.config.data }
+ } else {
+ entity = existingEntity
+ }
+ }
+ }
+
+ return { entity, params, api }
+}
+
+function normalizeRenderChartArgs(firstArg, secondArg, thirdArg) {
+ let entity = null
+ let params = {}
+ let api = null
+
+ if (isApiLike(secondArg) && thirdArg === undefined) {
+ api = secondArg
+ params = normalizeChartParams(firstArg)
+ } else {
+ entity = firstArg
+ params = normalizeChartParams(secondArg)
+ api = thirdArg
+ }
+
+ return { entity, params, api }
+}
+
+function inferChartType(entity, params) {
+ const configType = normalizeChartType(params?.config?.type)
+ if (configType) return configType
+
+ const entityType = normalizeChartType(entity?.type)
+ if (entityType) return entityType
+
+ const children = params?.children
+ if (Array.isArray(children)) {
+ if (children.some((child) => child?.type === "Bar")) return "bar"
+ if (children.some((child) => child?.type === "Area")) return "area"
+ if (children.some((child) => child?.type === "Line")) return "line"
+ if (children.some((child) => child?.type === "Pie")) return "pie"
+ }
+
+ return null
+}
+
+function normalizeChartType(type) {
+ if (!type || typeof type !== "string") return null
+ const lowered = type.toLowerCase()
+ if (lowered === "donut") return "pie"
+ if (lowered === "line") return "line"
+ if (lowered === "area") return "area"
+ if (lowered === "bar") return "bar"
+ if (lowered === "pie") return "pie"
+ if (lowered === "composed") return "composed"
+ return null
+}
+
+function isCartesianType(type) {
+ return type === "line" || type === "area" || type === "bar"
+}
+
+function isApiLike(value) {
+ return value && typeof value.getType === "function"
+}
+
+function normalizeChartParams(params) {
+ if (Array.isArray(params)) {
+ return { children: params, config: {} }
+ }
+
+ if (!params || typeof params !== "object") {
+ return { children: [], config: {} }
+ }
+
+ const { children, config, ...rest } = params
+
+ if (config && typeof config === "object") {
+ return {
+ children: normalizeChildren(children),
+ config: { ...config },
+ }
+ }
+
+ return {
+ children: normalizeChildren(children),
+ config: rest,
+ }
+}
+
+function normalizeChildren(children) {
+ if (children === undefined || children === null) return []
+ if (Array.isArray(children)) return children
+ return [children]
+}
+
+function buildInlineEntity(typeKey, config = {}) {
+ const entity = {
+ type: typeKey,
+ data: config.data ?? [],
+ __inline: true,
+ }
+
+ if (config.id != null) entity.id = config.id
+ if (config.width != null) entity.width = config.width
+ if (config.height != null) entity.height = config.height
+ if (config.showTooltip != null) entity.showTooltip = config.showTooltip
+
+ handlers.create(entity)
+
+ if (config.padding != null) entity.padding = config.padding
+ if (config.showTooltip != null) entity.showTooltip = config.showTooltip
+
+ ensureEntityIdentity(entity, config)
+
+ return entity
+}
+
+function getInlineEntity(typeKey, config = {}) {
+ const runtimeKey = config.key ?? config.data
+
+ if (runtimeKey && typeof runtimeKey === "object") {
+ const cached = inlineEntityCache.get(runtimeKey)
+ if (cached) return cached
+ const entity = buildInlineEntity(typeKey, config)
+ inlineEntityCache.set(runtimeKey, entity)
+ return entity
+ }
+
+ if (runtimeKey != null) {
+ const cacheKey = `${typeKey}:${String(runtimeKey)}`
+ const cached = inlineEntityKeyCache.get(cacheKey)
+ if (cached) return cached
+ const entity = buildInlineEntity(typeKey, config)
+ inlineEntityKeyCache.set(cacheKey, entity)
+ return entity
+ }
+
+ return buildInlineEntity(typeKey, config)
+}
+
+function hydrateEntityFromConfig(typeKey, entity, config = {}) {
+ if (!entity || typeof entity !== "object") return
+ if (Object.isFrozen(entity)) return
+
+ entity.type ||= typeKey
+
+ if (entity.data === undefined && config.data !== undefined) {
+ entity.data = config.data
+ }
+
+ ensureEntityIdentity(entity, config)
+}
+
+function ensureEntityIdentity(entity, config = {}) {
+ if (!entity || typeof entity !== "object") return
+ if (Object.isFrozen(entity)) return
+
+ if (config.id != null && (entity.id === undefined || entity.id === null)) {
+ entity.id = config.id
+ }
+
+ const runtimeKey = config.key ?? config.data
+ const runtimeId = ensureChartRuntimeIdWithKey(entity, runtimeKey)
+
+ if (entity.id === undefined || entity.id === null || entity.id === "") {
+ entity.id = runtimeId
+ }
+}
diff --git a/packages/charts/src/index.js b/packages/charts/src/index.js
index e1a13fd1..ef8b171a 100644
--- a/packages/charts/src/index.js
+++ b/packages/charts/src/index.js
@@ -1,267 +1,65 @@
-import { svg } from "@inglorious/web"
-
+import { coreCharts } from "./core/chart-core.js"
+import {
+ createChartInstance,
+ createInlineChartInstance,
+} from "./core/create-chart-instance.js"
+import { createDeclarativeChildren } from "./core/declarative-children.js"
+import { getEmptyChartInstance } from "./core/empty-instance.js"
+import { buildComponentRenderer, renderChart } from "./core/render-dispatch.js"
import * as handlers from "./handlers.js"
-import { render } from "./template.js"
-import { extractDataKeysFromChildren } from "./utils/extract-data-keys.js"
+
export { STREAM_DEFAULTS } from "./realtime/defaults.js"
export { lineChart } from "./realtime/stream-types.js"
export { withRealtime } from "./realtime/with-realtime.js"
-export { streamSlide } from "./utils/stream-slide.js"
-
-// Export chart types for config style
export {
areaChart,
barChart,
+ composedChart,
donutChart,
pieChart,
} from "./utils/chart-utils.js"
+export { streamSlide } from "./utils/stream-slide.js"
+
+const declarativeChildren = createDeclarativeChildren()
export const chart = {
...handlers,
- render,
+ core: coreCharts,
// Chart Delegators
- renderLineChart: createDelegator("line"),
- renderAreaChart: createDelegator("area"),
- renderBarChart: createDelegator("bar"),
- renderPieChart: createDelegator("pie"),
+ // Unified Composition-mode renderer
+ render: renderChart(),
// Component Renderers (Abstracted)
- renderLine: createComponentRenderer("renderLine", "line"),
- renderArea: createComponentRenderer("renderArea", "area"),
- renderBar: createComponentRenderer("renderBar", "bar"),
- renderPie: createComponentRenderer("renderPie", "pie"),
- renderYAxis: createComponentRenderer("renderYAxis"),
- renderTooltip: createComponentRenderer("renderTooltip"),
-
- // Lazy Renderers
- renderCartesianGrid: (entity, props, api) =>
- createLazyRenderer(entity, api, "renderCartesianGrid"),
- renderXAxis: (entity, props, api) =>
- createLazyRenderer(entity, api, "renderXAxis"),
- renderBrush: (entity, props, api) =>
- createLazyRenderer(entity, api, "renderBrush"),
+ renderLine: buildComponentRenderer("renderLine", "line"),
+ renderArea: buildComponentRenderer("renderArea", "area"),
+ renderBar: buildComponentRenderer("renderBar", "bar"),
+ renderPie: buildComponentRenderer("renderPie", "pie"),
+ renderYAxis: buildComponentRenderer("renderYAxis"),
+ renderTooltip: buildComponentRenderer("renderTooltip"),
+
+ // Component Renderers with optional chartType override from props
+ renderCartesianGrid: buildComponentRenderer(
+ "renderCartesianGrid",
+ null,
+ true,
+ ),
+ renderXAxis: buildComponentRenderer("renderXAxis", null, true),
+ renderBrush: buildComponentRenderer("renderBrush", null, true),
// Declarative Helpers for Composition Style (return intention objects)
- // The parent (renderLineChart, etc) processes these objects and "stamps" them with entity and api
- XAxis: (config = {}) => ({ type: "XAxis", config }),
- YAxis: (config = {}) => ({ type: "YAxis", config }),
- Line: (config = {}) => ({ type: "Line", config }),
- Area: (config = {}) => ({ type: "Area", config }),
- Bar: (config = {}) => ({ type: "Bar", config }),
- Pie: (config = {}) => ({ type: "Pie", config }),
- CartesianGrid: (config = {}) => ({ type: "CartesianGrid", config }),
- Tooltip: (config = {}) => ({ type: "Tooltip", config }),
- Brush: (config = {}) => ({ type: "Brush", config }),
- Dots: (config = {}) => ({ type: "Dots", config }),
- Legend: (config = {}) => ({ type: "Legend", config }),
+ ...declarativeChildren,
// Helper to create bound methods (reduces repetition)
forEntity(entityId, api) {
const entity = api.getEntity(entityId)
- return entity ? createInstance(entity, api) : getEmptyInstance()
+ return entity ? createChartInstance(entity, api) : getEmptyChartInstance()
},
// Create instance for inline charts (no entityId needed)
forEntityInline(api, tempEntity = null) {
- const entity = tempEntity || {
- id: `__temp_${Date.now()}`,
- type: "line", // Default, can be overridden by config
- data: [],
- }
- // Preserve showTooltip if explicitly set in tempEntity
- const preserveShowTooltip =
- tempEntity?.showTooltip !== undefined ? tempEntity.showTooltip : undefined
- // Initialize entity manually since it doesn't go through the store's create handler
- handlers.create(entity)
- // Restore showTooltip if it was explicitly set
- if (preserveShowTooltip !== undefined) {
- entity.showTooltip = preserveShowTooltip
- }
- return createInstance(entity, api, true) // true = inline mode
+ return createInlineChartInstance(api, tempEntity, handlers.create)
},
- createInstance,
-}
-
-function createInstance(entity, api, isInline = false) {
- let currentEntity = entity
-
- const createChartFactory =
- (chartType, renderMethod, forceStandard = false) =>
- (arg1 = {}, arg2 = []) => {
- const isLegacy = !forceStandard && Array.isArray(arg1)
- const config = isLegacy ? arg2 || {} : arg1
- const children = isLegacy ? arg1 : arg2
-
- if (isInline) {
- const resolvedData = config.data ?? currentEntity.data
- currentEntity = {
- ...currentEntity,
- type: config.type || chartType,
- ...(resolvedData ? { data: resolvedData } : null),
- width: config.width || currentEntity.width,
- height: config.height || currentEntity.height,
- }
- }
-
- const finalConfig = {
- ...config,
- data:
- config.data ||
- (!isInline && currentEntity.data ? currentEntity.data : undefined),
- // PieChart usually doesn't need dataKeys, but the extractor handles it
- dataKeys:
- chartType !== "pie"
- ? config.dataKeys || extractDataKeysFromChildren(children)
- : undefined,
- }
-
- return renderMethodOnType(
- currentEntity,
- renderMethod,
- {
- children: Array.isArray(children) ? children : [children],
- ...finalConfig,
- },
- api,
- )
- }
-
- // baseMethods return intention objects (don't render directly)
- // Processing happens in renderXxxChart which receives the children
- const baseMethods = {
- CartesianGrid: (cfg = {}) => ({ type: "CartesianGrid", config: cfg }),
- XAxis: (cfg = {}) => ({ type: "XAxis", config: cfg }),
- YAxis: (cfg = {}) => ({ type: "YAxis", config: cfg }),
- Tooltip: (cfg = {}) => ({ type: "Tooltip", config: cfg }),
- Brush: (cfg = {}) => ({ type: "Brush", config: cfg }),
- Line: (cfg = {}) => ({ type: "Line", config: cfg }),
- Area: (cfg = {}) => ({ type: "Area", config: cfg }),
- Bar: (cfg = {}) => ({ type: "Bar", config: cfg }),
- Pie: (cfg = {}) => ({ type: "Pie", config: cfg }),
- }
-
- const instance = {
- LineChart: createChartFactory("line", "renderLineChart", true),
- AreaChart: createChartFactory("area", "renderAreaChart", true),
- BarChart: createChartFactory("bar", "renderBarChart", true),
- PieChart: createChartFactory("pie", "renderPieChart", true),
-
- ...baseMethods,
-
- // Aliases for compatibility (renderX)
- renderLineChart: createChartFactory("line", "renderLineChart", false),
- renderAreaChart: createChartFactory("area", "renderAreaChart", false),
- renderBarChart: createChartFactory("bar", "renderBarChart", false),
- renderPieChart: createChartFactory("pie", "renderPieChart", false),
- renderCartesianGrid: baseMethods.CartesianGrid,
- renderXAxis: baseMethods.XAxis,
- renderYAxis: baseMethods.YAxis,
- renderLine: baseMethods.Line,
- renderArea: baseMethods.Area,
- renderBar: baseMethods.Bar,
- renderPie: baseMethods.Pie,
- renderTooltip: baseMethods.Tooltip,
- renderBrush: baseMethods.Brush,
-
- // Dots and Legend also return intention objects
- // Processing happens in renderXxxChart which receives the children
- renderDots: (config = {}) => ({ type: "Dots", config }),
- renderLegend: (config = {}) => ({ type: "Legend", config }),
- }
-
- // Synchronize PascalCase names with camelCase aliases
- instance.Dots = instance.renderDots
- instance.Legend = instance.renderLegend
-
- return instance
-}
-
-function createDelegator(typeKey) {
- const firstCharIndex = 0
- const restStartIndex = 1
- const firstChar = typeKey.charAt(firstCharIndex)
- const rest = typeKey.slice(restStartIndex)
- const methodName = `render${firstChar.toUpperCase() + rest}Chart`
-
- return function delegateToChartType(entity, params, api) {
- if (!entity) return renderEmptyTemplate()
- const chartType = api.getType(typeKey)
- return chartType?.[methodName]
- ? chartType[methodName](entity, params, api)
- : renderEmptyTemplate()
- }
-}
-
-function createLazyRenderer(entity, api, methodName) {
- return function renderLazy(ctx) {
- if (!entity) return renderEmptyTemplate()
- const chartTypeName = ctx?.chartType || entity.type
- const chartType = api.getType(chartTypeName)
- return chartType?.[methodName]
- ? chartType[methodName](entity, { config: ctx?.config || {} }, api)
- : renderEmptyTemplate()
- }
-}
-
-function createComponentRenderer(methodName, typeOverride = null) {
- return function renderComponent(entity, { config = {} }, api) {
- if (!entity) return renderEmptyTemplate()
- const type = api.getType(typeOverride || entity.type)
- return type?.[methodName]
- ? type[methodName](entity, { config }, api)
- : renderEmptyTemplate()
- }
-}
-
-function renderMethodOnType(entity, methodName, params, api) {
- const type = api.getType(entity.type)
- return type?.[methodName]
- ? type[methodName](entity, params, api)
- : renderEmptyTemplate()
-}
-
-function renderEmptyTemplate() {
- return svg``
-}
-
-function renderEmptyLazyTemplate() {
- return renderEmptyTemplate
-}
-
-function getEmptyInstance() {
- return {
- renderLineChart: renderEmptyTemplate,
- renderAreaChart: renderEmptyTemplate,
- renderBarChart: renderEmptyTemplate,
- renderPieChart: renderEmptyTemplate,
- renderCartesianGrid: renderEmptyLazyTemplate,
- renderXAxis: renderEmptyLazyTemplate,
- renderYAxis: renderEmptyTemplate,
- renderLegend: renderEmptyLazyTemplate,
- renderLine: renderEmptyTemplate,
- renderArea: renderEmptyTemplate,
- renderBar: renderEmptyTemplate,
- renderPie: renderEmptyTemplate,
- renderDots: renderEmptyLazyTemplate,
- renderTooltip: renderEmptyTemplate,
- renderBrush: renderEmptyLazyTemplate,
- // Composition Style
- LineChart: renderEmptyTemplate,
- AreaChart: renderEmptyTemplate,
- BarChart: renderEmptyTemplate,
- PieChart: renderEmptyTemplate,
- CartesianGrid: renderEmptyLazyTemplate,
- XAxis: renderEmptyLazyTemplate,
- YAxis: renderEmptyTemplate,
- Line: renderEmptyTemplate,
- Area: renderEmptyTemplate,
- Bar: renderEmptyTemplate,
- Pie: renderEmptyTemplate,
- Dots: renderEmptyLazyTemplate,
- Tooltip: renderEmptyTemplate,
- Brush: renderEmptyLazyTemplate,
- Legend: renderEmptyLazyTemplate,
- }
+ createInstance: createChartInstance,
}
diff --git a/packages/charts/src/polar/pie.js b/packages/charts/src/polar/pie.js
index e4288025..92385c28 100644
--- a/packages/charts/src/polar/pie.js
+++ b/packages/charts/src/polar/pie.js
@@ -4,6 +4,7 @@ import { html, repeat, svg } from "@inglorious/web"
import { createTooltipComponent, renderTooltip } from "../component/tooltip.js"
import { renderSector } from "../shape/sector.js"
+import { getResolvedEntity } from "../utils/cartesian-helpers.js"
import { formatNumber } from "../utils/data-utils.js"
import { calculatePieData } from "../utils/paths.js"
import { processDeclarativeChild } from "../utils/process-declarative-child.js"
@@ -346,7 +347,7 @@ export const pie = {
// eslint-disable-next-line no-unused-vars
renderPie(entity, { config = {} }, api) {
const pieFn = (ctx) => {
- const entityFromContext = ctx.entity || entity
+ const entityFromContext = getResolvedEntity(ctx, entity)
if (!entityFromContext.data || entityFromContext.data.length === 0) {
return svg``
}
diff --git a/packages/charts/src/utils/cartesian-children.js b/packages/charts/src/utils/cartesian-children.js
new file mode 100644
index 00000000..8ece7960
--- /dev/null
+++ b/packages/charts/src/utils/cartesian-children.js
@@ -0,0 +1,65 @@
+/* eslint-disable no-magic-numbers */
+import { DEFAULT_GRID_CONFIG, DEFAULT_Y_AXIS_CONFIG } from "./constants.js"
+
+export function resolveXAxisDataKey(entity) {
+ let dataKey = entity?.dataKey
+ if (!dataKey && Array.isArray(entity?.data) && entity.data.length > 0) {
+ const firstItem = entity.data[0]
+ dataKey = firstItem?.name || firstItem?.x || firstItem?.date || "name"
+ }
+ return dataKey || "name"
+}
+
+export function buildCartesianBaseChildren(
+ entity,
+ {
+ makeChild = (type, config) => ({ type, config }),
+ includeGrid = true,
+ includeXAxis = true,
+ includeYAxis = true,
+ includeTooltip = true,
+ includeBrush = true,
+ gridConfig = DEFAULT_GRID_CONFIG,
+ xAxisConfig = {},
+ yAxisConfig = DEFAULT_Y_AXIS_CONFIG,
+ tooltipConfig = {},
+ brushConfig = {},
+ } = {},
+) {
+ const children = []
+ if (!entity) return children
+
+ const xAxisDataKey = resolveXAxisDataKey(entity)
+
+ if (includeGrid && entity.showGrid !== false) {
+ children.push(makeChild("CartesianGrid", gridConfig))
+ }
+
+ if (includeXAxis) {
+ children.push(makeChild("XAxis", { dataKey: xAxisDataKey, ...xAxisConfig }))
+ }
+
+ if (includeYAxis) {
+ children.push(makeChild("YAxis", { ...yAxisConfig }))
+ }
+
+ if (includeTooltip && entity.showTooltip !== false) {
+ children.push(makeChild("Tooltip", tooltipConfig))
+ }
+
+ if (
+ includeBrush &&
+ entity.brush?.enabled &&
+ entity.brush?.visible !== false
+ ) {
+ children.push(
+ makeChild("Brush", {
+ dataKey: xAxisDataKey,
+ height: entity.brush.height || 30,
+ ...brushConfig,
+ }),
+ )
+ }
+
+ return children
+}
diff --git a/packages/charts/src/utils/cartesian-layout.js b/packages/charts/src/utils/cartesian-geometry.js
similarity index 100%
rename from packages/charts/src/utils/cartesian-layout.js
rename to packages/charts/src/utils/cartesian-geometry.js
diff --git a/packages/charts/src/utils/cartesian-helpers.js b/packages/charts/src/utils/cartesian-helpers.js
new file mode 100644
index 00000000..cab99e16
--- /dev/null
+++ b/packages/charts/src/utils/cartesian-helpers.js
@@ -0,0 +1,63 @@
+/* eslint-disable no-magic-numbers */
+export const DEFAULT_SERIES_INDEX = 0
+
+export function inferSeriesDataKey(data, preferredKey) {
+ if (!Array.isArray(data) || data.length === DEFAULT_SERIES_INDEX) {
+ return undefined
+ }
+ const sample = data[DEFAULT_SERIES_INDEX]
+ if (!sample || typeof sample !== "object") return undefined
+
+ if (preferredKey && typeof sample[preferredKey] === "number") {
+ return preferredKey
+ }
+
+ if (typeof sample.value === "number") return "value"
+ if (typeof sample.y === "number") return "y"
+
+ const numericKeys = Object.keys(sample).filter(
+ (key) =>
+ !["name", "label", "x", "date"].includes(key) &&
+ typeof sample[key] === "number",
+ )
+ return numericKeys[DEFAULT_SERIES_INDEX]
+}
+
+export function resolveCategoryLabel(entity, index) {
+ const item = entity?.data?.[index]
+ return (
+ item?.label ??
+ item?.name ??
+ item?.category ??
+ item?.x ??
+ item?.date ??
+ String(index)
+ )
+}
+
+export function createBandCenterScale(bandScale) {
+ const scale = (value) => {
+ const base = bandScale(value)
+ if (base == null || Number.isNaN(base)) return base
+ return base + bandScale.bandwidth() / 2
+ }
+ scale.domain = () => bandScale.domain()
+ scale.range = () => bandScale.range()
+ return scale
+}
+
+/**
+ * Resolves the most appropriate entity from render context.
+ * Prefers context-specific entity, then fullEntity (for brush / overlays),
+ * and finally falls back to the original entity argument.
+ *
+ * @param {Record} ctx
+ * @param {import("../types/charts").ChartEntity} entity
+ * @returns {import("../types/charts").ChartEntity | undefined}
+ */
+export function getResolvedEntity(ctx, entity) {
+ if (!ctx) return entity
+ if (ctx.entity) return ctx.entity
+ if (ctx.fullEntity) return ctx.fullEntity
+ return entity
+}
diff --git a/packages/charts/src/utils/cartesian-helpers.test.js b/packages/charts/src/utils/cartesian-helpers.test.js
new file mode 100644
index 00000000..a8be0c3f
--- /dev/null
+++ b/packages/charts/src/utils/cartesian-helpers.test.js
@@ -0,0 +1,35 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, expect, it } from "vitest"
+
+import { getResolvedEntity } from "./cartesian-helpers.js"
+
+describe("cartesian-helpers", () => {
+ describe("getResolvedEntity", () => {
+ const baseEntity = { id: "base" }
+
+ it("should return ctx.entity when present", () => {
+ const ctx = { entity: { id: "entity" }, fullEntity: { id: "full" } }
+ const result = getResolvedEntity(ctx, baseEntity)
+ expect(result).toBe(ctx.entity)
+ })
+
+ it("should return ctx.fullEntity when entity is missing", () => {
+ const ctx = { fullEntity: { id: "full" } }
+ const result = getResolvedEntity(ctx, baseEntity)
+ expect(result).toBe(ctx.fullEntity)
+ })
+
+ it("should fall back to original entity when ctx has no entity/fullEntity", () => {
+ const ctx = {}
+ const result = getResolvedEntity(ctx, baseEntity)
+ expect(result).toBe(baseEntity)
+ })
+
+ it("should handle missing ctx gracefully", () => {
+ const result = getResolvedEntity(null, baseEntity)
+ expect(result).toBe(baseEntity)
+ })
+ })
+})
diff --git a/packages/charts/src/utils/cartesian-renderer.js b/packages/charts/src/utils/cartesian-renderer.js
new file mode 100644
index 00000000..2535b7ee
--- /dev/null
+++ b/packages/charts/src/utils/cartesian-renderer.js
@@ -0,0 +1,280 @@
+/* eslint-disable no-magic-numbers */
+import {
+ buildCartesianBaseChildren,
+ resolveXAxisDataKey,
+} from "./cartesian-children.js"
+import { PALETTE_DEFAULT } from "./constants.js"
+import { isMultiSeries, resolveDataKeys } from "./data-utils.js"
+
+/**
+ * Converts long multi-series input into wide rows, reusing the x value as row key.
+ * This keeps chart renderers working with one normalized data shape.
+ *
+ * @param {any[]} multiSeriesData
+ * @returns {any[] | null}
+ */
+export function transformSeriesToWide(multiSeriesData) {
+ if (!isMultiSeries(multiSeriesData)) return null
+
+ const dataMap = new Map()
+ const seriesKeys = multiSeriesData.map((series) => {
+ return series.dataKey || series.name || "series"
+ })
+
+ multiSeriesData.forEach((series, seriesIndex) => {
+ const seriesKey = seriesKeys[seriesIndex]
+ const values = Array.isArray(series.values) ? series.values : [series]
+
+ values.forEach((point, pointIndex) => {
+ const xValue = point?.x ?? pointIndex
+ if (!dataMap.has(xValue)) {
+ dataMap.set(xValue, { x: xValue, name: String(xValue) })
+ }
+ dataMap.get(xValue)[seriesKey] = point?.y ?? point?.value ?? 0
+ })
+ })
+
+ return Array.from(dataMap.values()).sort((a, b) => {
+ return typeof a.x === "number"
+ ? a.x - b.x
+ : String(a.x).localeCompare(String(b.x))
+ })
+}
+
+/**
+ * Creates declarative children from config-mode entity options.
+ * This is shared by line and area renderers to keep the same defaults.
+ *
+ * @param {any} entity
+ * @param {Object} options
+ * @param {any} options.chartApi
+ * @param {"line"|"area"} options.seriesType
+ * @param {string[] | null} [options.providedDataKeys]
+ * @returns {any[]}
+ */
+export function buildCartesianChildrenFromConfig(
+ entity,
+ { chartApi, seriesType, providedDataKeys = null },
+) {
+ const children = buildCartesianBaseChildren(entity, {
+ makeChild: (type, config) => chartApi[type](config),
+ includeTooltip: false,
+ includeBrush: false,
+ })
+
+ const dataKeys = providedDataKeys?.length
+ ? providedDataKeys
+ : resolveDataKeys(entity.data)
+
+ const colors = entity.colors || PALETTE_DEFAULT
+ const isStackedArea = seriesType === "area" && entity.stacked === true
+
+ dataKeys.forEach((dataKey, index) => {
+ if (seriesType === "line") {
+ children.push(
+ chartApi.Line({
+ dataKey,
+ stroke: colors[index % colors.length],
+ showDots: entity.showPoints !== false,
+ }),
+ )
+ return
+ }
+
+ children.push(
+ chartApi.Area({
+ dataKey,
+ fill: colors[index % colors.length],
+ fillOpacity: "0.6",
+ stroke: colors[index % colors.length],
+ stackId: isStackedArea ? "1" : undefined,
+ }),
+ )
+ })
+
+ if (entity.showPoints !== false && dataKeys.length > 0) {
+ dataKeys.forEach((dataKey, index) => {
+ children.push(
+ chartApi.Dots({
+ dataKey,
+ fill: colors[index % colors.length],
+ stackId: isStackedArea ? "1" : undefined,
+ }),
+ )
+ })
+ }
+
+ if (entity.showTooltip !== false) {
+ children.push(chartApi.Tooltip({}))
+ }
+
+ if (
+ seriesType === "line" &&
+ entity.showLegend &&
+ isMultiSeries(entity.data)
+ ) {
+ children.push(
+ chartApi.Legend({
+ dataKeys,
+ labels: entity.labels || dataKeys,
+ colors: entity.colors,
+ }),
+ )
+ }
+
+ if (
+ seriesType === "area" &&
+ entity.showLegend === true &&
+ dataKeys.length > 1
+ ) {
+ children.push(
+ chartApi.Legend({
+ dataKeys,
+ colors: colors.slice(0, dataKeys.length),
+ }),
+ )
+ }
+
+ if (entity.brush?.enabled && entity.brush?.visible !== false) {
+ children.push(
+ chartApi.Brush({
+ dataKey: resolveXAxisDataKey(entity),
+ height: entity.brush.height || 30,
+ }),
+ )
+ }
+
+ return children
+}
+
+/**
+ * Sorts processed child functions by render layer.
+ * Grid goes first, then series, then axes, and overlays later.
+ *
+ * @param {any[]} processedChildren
+ * @param {Object} options
+ * @param {"isLine"|"isArea"} options.seriesFlag
+ * @param {boolean} [options.reverseSeries=false]
+ * @param {boolean} [options.includeBrush=false]
+ * @returns {{ orderedChildren: any[], buckets: Record }}
+ */
+export function sortChildrenByLayer(
+ processedChildren,
+ { seriesFlag, reverseSeries = false, includeBrush = false },
+) {
+ const seriesFlags = Array.isArray(seriesFlag) ? seriesFlag : [seriesFlag]
+ const buckets = {
+ grid: [],
+ axes: [],
+ series: [],
+ dots: [],
+ tooltip: [],
+ legend: [],
+ brush: [],
+ others: [],
+ }
+
+ for (const child of processedChildren) {
+ if (typeof child !== "function") {
+ buckets.others.push(child)
+ continue
+ }
+ if (child.isGrid) buckets.grid.push(child)
+ else if (child.isAxis) buckets.axes.push(child)
+ else if (seriesFlags.some((flag) => child[flag])) buckets.series.push(child)
+ else if (child.isDots) buckets.dots.push(child)
+ else if (child.isTooltip) buckets.tooltip.push(child)
+ else if (child.isLegend) buckets.legend.push(child)
+ else if (child.isBrush) buckets.brush.push(child)
+ else buckets.others.push(child)
+ }
+
+ const orderedSeries = reverseSeries
+ ? [...buckets.series].reverse()
+ : buckets.series
+
+ const orderedChildren = [
+ ...buckets.grid,
+ ...orderedSeries,
+ ...buckets.axes,
+ ...buckets.dots,
+ ...buckets.tooltip,
+ ...buckets.legend,
+ ...(includeBrush ? buckets.brush : []),
+ ...buckets.others,
+ ]
+
+ return { orderedChildren, buckets }
+}
+
+/**
+ * Creates a config-mode renderer for cartesian charts.
+ * It normalizes data, builds declarative children, and delegates to composition render.
+ *
+ * @param {Object} options
+ * @param {"line"|"area"} options.seriesType
+ * @param {any | (() => any)} options.chartApi
+ * @param {(args: { entity: any; transformedData: any[] }) => any[]} [options.toDisplayData]
+ * @param {(args: { entity: any; entityWithData: any; transformedData: any[]; dataKeys: string[] }) => Record} options.buildRenderConfig
+ * @returns {(entity: any, api: any) => any}
+ */
+export function createCartesianRenderer({
+ seriesType,
+ chartApi,
+ toDisplayData = ({ transformedData }) => transformedData,
+ buildRenderConfig,
+}) {
+ const renderMethod = getRenderMethod(seriesType)
+
+ return function render(entity, api) {
+ const type = api.getType(entity.type)
+ const resolvedChartApi =
+ typeof chartApi === "function" ? chartApi() : chartApi
+
+ let transformedData = entity.data
+ let dataKeys = []
+
+ if (isMultiSeries(entity.data)) {
+ dataKeys = entity.data.map((series, index) => {
+ return series.dataKey || series.name || series.label || `series${index}`
+ })
+ transformedData = transformSeriesToWide(entity.data)
+ }
+
+ const displayData = toDisplayData({ entity, transformedData })
+ const entityWithData = {
+ ...entity,
+ data: displayData,
+ dataKey:
+ entity.dataKey ||
+ (transformedData?.[0]?.x !== undefined ? "x" : "name"),
+ }
+
+ const children = buildCartesianChildrenFromConfig(entityWithData, {
+ chartApi: resolvedChartApi,
+ seriesType,
+ providedDataKeys: dataKeys,
+ })
+
+ const resolvedDataKeys =
+ dataKeys.length > 0 ? dataKeys : resolveDataKeys(entityWithData.data)
+
+ return type[renderMethod](
+ entityWithData,
+ {
+ children,
+ config: buildRenderConfig({
+ entity,
+ entityWithData,
+ transformedData,
+ dataKeys: resolvedDataKeys,
+ }),
+ },
+ api,
+ )
+ }
+}
+
+function getRenderMethod(seriesType) {
+ return seriesType === "area" ? "renderAreaChart" : "renderLineChart"
+}
diff --git a/packages/charts/src/utils/chart-utils.js b/packages/charts/src/utils/chart-utils.js
index 92a43f03..c159c014 100644
--- a/packages/charts/src/utils/chart-utils.js
+++ b/packages/charts/src/utils/chart-utils.js
@@ -5,6 +5,7 @@
import { area } from "../cartesian/area.js"
import { bar } from "../cartesian/bar.js"
+import { composed } from "../cartesian/composed.js"
import { line } from "../cartesian/line.js"
import * as handlers from "../handlers.js"
import { donut } from "../polar/donut.js"
@@ -13,6 +14,7 @@ import { render } from "../template.js"
export const areaChart = combineRenderer(area)
export const barChart = combineRenderer(bar)
+export const composedChart = combineRenderer(composed)
export const lineChart = combineRenderer(line)
export const pieChart = combineRenderer(pie)
export const donutChart = combineRenderer(donut)
diff --git a/packages/charts/src/utils/colors.js b/packages/charts/src/utils/colors.js
index 344d0658..023d14f9 100644
--- a/packages/charts/src/utils/colors.js
+++ b/packages/charts/src/utils/colors.js
@@ -1,6 +1,6 @@
/* eslint-disable no-magic-numbers */
-const DEFAULT_COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"]
+import { PALETTE_DEFAULT } from "./constants"
const EXTENDED_COLORS = [
"#3b82f6",
@@ -73,5 +73,5 @@ export function generateColors(count, customColors = null) {
* @returns {string[]}
*/
export function getDefaultColors() {
- return [...DEFAULT_COLORS]
+ return [...PALETTE_DEFAULT]
}
diff --git a/packages/charts/src/utils/constants.js b/packages/charts/src/utils/constants.js
new file mode 100644
index 00000000..4a414e52
--- /dev/null
+++ b/packages/charts/src/utils/constants.js
@@ -0,0 +1,28 @@
+/**
+ * Recharts-style palette used for cartesian series (Line/Area/Bar)
+ * when no explicit `entity.colors` are provided.
+ */
+export const PALETTE_ACCENT = ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"]
+
+export const PALETTE_DEFAULT = [
+ "#3b82f6",
+ "#ef4444",
+ "#10b981",
+ "#f59e0b",
+ "#8b5cf6",
+]
+
+/**
+ * Shared defaults for cartesian grid.
+ */
+export const DEFAULT_GRID_CONFIG = {
+ stroke: "#eee",
+ strokeDasharray: "5 5",
+}
+
+/**
+ * Shared defaults for cartesian Y axis.
+ */
+export const DEFAULT_Y_AXIS_CONFIG = {
+ width: "auto",
+}
diff --git a/packages/charts/src/utils/data-utils.js b/packages/charts/src/utils/data-utils.js
index 706ceca5..74232237 100644
--- a/packages/charts/src/utils/data-utils.js
+++ b/packages/charts/src/utils/data-utils.js
@@ -8,6 +8,8 @@
import { format } from "d3-format"
import { timeFormat } from "d3-time-format"
+const EXCLUDED_AXIS_KEYS = new Set(["name", "label", "x", "date"])
+
/**
* Format a number with the specified format
* @param {number} value - Number to format
@@ -115,6 +117,32 @@ export function ensureFiniteNumber(value, fallback = 0) {
return Number.isFinite(parsed) ? parsed : fallback
}
+/**
+ * Infers numeric data keys from chart data to build series.
+ * Supports both multi-series and flat wide data.
+ *
+ * @param {any[]} data
+ * @returns {string[]}
+ */
+export function resolveDataKeys(data) {
+ if (!Array.isArray(data) || data.length === 0) return ["value"]
+
+ if (isMultiSeries(data)) {
+ return data.map((series, index) => {
+ return series.dataKey || series.name || series.label || `series${index}`
+ })
+ }
+
+ const first = data[0]
+ const keys = Object.keys(first).filter((key) => {
+ return !EXCLUDED_AXIS_KEYS.has(key) && typeof first[key] === "number"
+ })
+ if (keys.length > 0) return keys
+
+ const fallback = ["y", "value"].filter((key) => first[key] !== undefined)
+ return fallback.length > 0 ? fallback : ["value"]
+}
+
/**
* Transforms entity data to standardized format with x, y, and name properties.
* Used for rendering lines and areas in composition mode.
diff --git a/packages/charts/src/utils/data-utils.test.js b/packages/charts/src/utils/data-utils.test.js
index 8407a503..cd04f33b 100644
--- a/packages/charts/src/utils/data-utils.test.js
+++ b/packages/charts/src/utils/data-utils.test.js
@@ -15,6 +15,7 @@ import {
isMultiSeries,
isValidNumber,
parseDimension,
+ resolveDataKeys,
} from "./data-utils.js"
describe("data-utils", () => {
@@ -207,4 +208,46 @@ describe("data-utils", () => {
expect(getTransformedData({ id: "test" }, "value")).toBeNull()
})
})
+
+ describe("resolveDataKeys", () => {
+ it("should infer keys from wide numeric data excluding axis keys", () => {
+ const data = [
+ { name: "Jan", value: 100, other: 50, label: "A" },
+ { name: "Feb", value: 200, other: 75, label: "B" },
+ ]
+
+ const keys = resolveDataKeys(data)
+
+ // Should ignore name/label and pick numeric keys
+ expect(keys).toContain("value")
+ expect(keys).toContain("other")
+ expect(keys).not.toContain("name")
+ expect(keys).not.toContain("label")
+ })
+
+ it("should infer series keys for multi-series data", () => {
+ const data = [
+ { name: "Series A", values: [{ x: 0, y: 10 }] },
+ { label: "Series B", values: [{ x: 0, y: 20 }] },
+ { values: [{ x: 0, y: 30 }] },
+ ]
+
+ const keys = resolveDataKeys(data)
+
+ expect(keys).toEqual(["Series A", "Series B", "series2"])
+ })
+
+ it("should fallback to y or value when no numeric keys found", () => {
+ const dataWithY = [{ name: "Jan", y: 100 }]
+ const dataWithValue = [{ name: "Jan", value: 200 }]
+
+ expect(resolveDataKeys(dataWithY)).toEqual(["y"])
+ expect(resolveDataKeys(dataWithValue)).toEqual(["value"])
+ })
+
+ it("should return default key for empty or invalid data", () => {
+ expect(resolveDataKeys([])).toEqual(["value"])
+ expect(resolveDataKeys(null)).toEqual(["value"])
+ })
+ })
})
diff --git a/packages/charts/src/utils/process-declarative-child.js b/packages/charts/src/utils/process-declarative-child.js
index 4d0f2502..3c85a5ff 100644
--- a/packages/charts/src/utils/process-declarative-child.js
+++ b/packages/charts/src/utils/process-declarative-child.js
@@ -15,7 +15,9 @@ export function processDeclarativeChild(child, entity, chartTypeName, api) {
child.type &&
child.config !== undefined
) {
- const chartType = api.getType(chartTypeName)
+ const resolvedChartTypeName =
+ getRendererChartType(child.type) || chartTypeName
+ const chartType = api.getType(resolvedChartTypeName)
const methodName = `render${child.type}`
if (chartType?.[methodName]) {
@@ -44,3 +46,11 @@ export function processDeclarativeChild(child, entity, chartTypeName, api) {
}
return child
}
+
+function getRendererChartType(childType) {
+ if (childType === "Line") return "line"
+ if (childType === "Area") return "area"
+ if (childType === "Bar") return "bar"
+ if (childType === "Pie") return "pie"
+ return null
+}
diff --git a/packages/charts/src/utils/runtime-id.js b/packages/charts/src/utils/runtime-id.js
new file mode 100644
index 00000000..32b6c01a
--- /dev/null
+++ b/packages/charts/src/utils/runtime-id.js
@@ -0,0 +1,70 @@
+let runtimeIdCounter = 0
+const RUNTIME_ID_INCREMENT = 1
+const runtimeIdCache = new WeakMap()
+const runtimeDataIdCache = new WeakMap()
+const runtimeKeyIdCache = new WeakMap()
+
+export function ensureChartRuntimeId(entity) {
+ if (!entity || typeof entity !== "object") return "chart-runtime-unknown"
+
+ if (entity.__runtimeId) return String(entity.__runtimeId)
+ if (entity.runtimeId !== undefined && entity.runtimeId !== null) {
+ return String(entity.runtimeId)
+ }
+
+ if (entity.id) return String(entity.id)
+
+ if (runtimeIdCache.has(entity)) {
+ return runtimeIdCache.get(entity)
+ }
+
+ if (entity.data && typeof entity.data === "object") {
+ const cachedByData = runtimeDataIdCache.get(entity.data)
+ if (cachedByData) {
+ runtimeIdCache.set(entity, cachedByData)
+ return cachedByData
+ }
+ }
+
+ if (!entity.__runtimeId) {
+ runtimeIdCounter += RUNTIME_ID_INCREMENT
+ const nextId = `chart-runtime-${runtimeIdCounter}`
+ Object.defineProperty(entity, "__runtimeId", {
+ value: nextId,
+ writable: true,
+ configurable: true,
+ enumerable: false,
+ })
+ }
+
+ runtimeIdCache.set(entity, entity.__runtimeId)
+ if (entity.data && typeof entity.data === "object") {
+ runtimeDataIdCache.set(entity.data, entity.__runtimeId)
+ }
+ return entity.__runtimeId
+}
+
+export function ensureChartRuntimeIdWithKey(entity, key) {
+ if (key && typeof key === "object") {
+ const cached = runtimeKeyIdCache.get(key)
+ if (cached) {
+ if (entity && typeof entity === "object") {
+ Object.defineProperty(entity, "__runtimeId", {
+ value: cached,
+ writable: true,
+ configurable: true,
+ enumerable: false,
+ })
+ }
+ return cached
+ }
+ }
+
+ const runtimeId = ensureChartRuntimeId(entity)
+
+ if (key && typeof key === "object") {
+ runtimeKeyIdCache.set(key, runtimeId)
+ }
+
+ return runtimeId
+}
diff --git a/packages/charts/src/utils/tooltip-handlers.js b/packages/charts/src/utils/tooltip-handlers.js
index b50bd2bc..afbc13ba 100644
--- a/packages/charts/src/utils/tooltip-handlers.js
+++ b/packages/charts/src/utils/tooltip-handlers.js
@@ -3,6 +3,8 @@
* Creates reusable onMouseEnter, onMouseLeave, and onMouseMove handlers for chart elements
*/
+import * as handlers from "../handlers.js"
+
// Tooltip offset from cursor position
const TOOLTIP_OFFSET = 15
// Estimated tooltip width (can be adjusted based on actual tooltip size)
@@ -21,9 +23,15 @@ const TOOLTIP_ESTIMATED_HEIGHT = 60
* @param {string} params.tooltipData.color - Tooltip color
* @returns {{ onMouseEnter: Function, onMouseLeave: Function }}
*/
-export function createTooltipHandlers({ entity, api, tooltipData }) {
+export function createTooltipHandlers({
+ entity,
+ api,
+ tooltipData,
+ enabled = undefined,
+}) {
+ const isEnabled = enabled ?? entity.showTooltip
const onMouseEnter = (e) => {
- if (!entity.showTooltip) return
+ if (!isEnabled) return
const svgElement = e.currentTarget.closest("svg")
const svgRect = svgElement.getBoundingClientRect()
const containerElement =
@@ -40,6 +48,18 @@ export function createTooltipHandlers({ entity, api, tooltipData }) {
containerRect,
)
+ if (entity.__inline || typeof api?.notify !== "function") {
+ handlers.tooltipShow(entity, {
+ label: tooltipData.label,
+ value: tooltipData.value,
+ color: tooltipData.color,
+ x,
+ y,
+ })
+ api?.notify?.("charts:inlineTooltip")
+ return
+ }
+
api.notify(`#${entity.id}:tooltipShow`, {
label: tooltipData.label,
value: tooltipData.value,
@@ -50,7 +70,13 @@ export function createTooltipHandlers({ entity, api, tooltipData }) {
}
const onMouseLeave = () => {
- if (!entity.showTooltip) return
+ if (!isEnabled) return
+ if (entity.__inline || typeof api?.notify !== "function") {
+ handlers.tooltipHide(entity)
+ api?.notify?.("charts:inlineTooltip")
+ return
+ }
+
api.notify(`#${entity.id}:tooltipHide`)
}
@@ -83,6 +109,12 @@ export function createTooltipMoveHandler({ entity, api }) {
containerRect,
)
+ if (entity.__inline || typeof api?.notify !== "function") {
+ handlers.tooltipMove(entity, { x, y })
+ api?.notify?.("charts:inlineTooltip")
+ return
+ }
+
api.notify(`#${entity.id}:tooltipMove`, {
x,
y,
diff --git a/skills-lock.json b/skills-lock.json
index 7155d227..6430df7e 100644
--- a/skills-lock.json
+++ b/skills-lock.json
@@ -2,7 +2,7 @@
"version": 1,
"skills": {
"forge-skills": {
- "source": "IngloriousCoderz/forge-skills",
+ "source": "ingloriouscoderz/forge-skills",
"sourceType": "github",
"computedHash": "c6968cc22c6bed7961379f289284194fd217607384af89c6257a1d12c1f49da6"
}