From d01441fc107acba80e6998f920fcb2d73ac7022c Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 6 Mar 2026 10:26:35 +0100 Subject: [PATCH 01/34] refactor: extract shared cartesian renderer and rename layout util --- packages/charts/src/cartesian/area.js | 238 ++------------ packages/charts/src/cartesian/bar.js | 4 +- packages/charts/src/cartesian/line.js | 275 ++-------------- ...tesian-layout.js => cartesian-geometry.js} | 0 .../charts/src/utils/cartesian-renderer.js | 311 ++++++++++++++++++ 5 files changed, 371 insertions(+), 457 deletions(-) rename packages/charts/src/utils/{cartesian-layout.js => cartesian-geometry.js} (100%) create mode 100644 packages/charts/src/utils/cartesian-renderer.js diff --git a/packages/charts/src/cartesian/area.js b/packages/charts/src/cartesian/area.js index e16ccde4..f0c96633 100644 --- a/packages/charts/src/cartesian/area.js +++ b/packages/charts/src/cartesian/area.js @@ -10,7 +10,11 @@ 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 { + createCartesianRenderer, + sortChildrenByLayer, +} from "../utils/cartesian-renderer.js" +import { getTransformedData } from "../utils/data-utils.js" import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js" import { generateAreaPath, @@ -22,72 +26,17 @@ import { createSharedContext } from "../utils/shared-context.js" import { createTooltipHandlers } from "../utils/tooltip-handlers.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. @@ -162,37 +111,13 @@ export const area = { ) .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 { orderedChildren: sortedChildren } = sortChildrenByLayer( + processedChildrenArray, + { + seriesFlag: "isArea", + reverseSeries: !isStacked, + }, + ) const finalRendered = sortedChildren.map((child) => { if (typeof child !== "function") return child @@ -437,120 +362,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/bar.js b/packages/charts/src/cartesian/bar.js index 86d6b773..d42e6029 100644 --- a/packages/charts/src/cartesian/bar.js +++ b/packages/charts/src/cartesian/bar.js @@ -9,8 +9,8 @@ import { createTooltipComponent, renderTooltip } from "../component/tooltip.js" import { renderXAxis } from "../component/x-axis.js" import { renderYAxis } from "../component/y-axis.js" import { renderRectangle } from "../shape/rectangle.js" -import { renderCartesianLayout } from "../utils/cartesian-layout.js" -import { resolveChartDimensions } from "../utils/chart-dimensions.js" +import { renderCartesianLayout } from "../utils/cartesian-geometry.js" +import { calculatePadding } from "../utils/padding.js" import { processDeclarativeChild } from "../utils/process-declarative-child.js" import { createCartesianContext } from "../utils/scales.js" import { createTooltipHandlers } from "../utils/tooltip-handlers.js" diff --git a/packages/charts/src/cartesian/line.js b/packages/charts/src/cartesian/line.js index af04f3ff..6a0d6dfe 100644 --- a/packages/charts/src/cartesian/line.js +++ b/packages/charts/src/cartesian/line.js @@ -9,8 +9,11 @@ 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 { + createCartesianRenderer, + sortChildrenByLayer, +} from "../utils/cartesian-renderer.js" +import { getTransformedData, parseDimension } from "../utils/data-utils.js" import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js" import { generateLinePath } from "../utils/paths.js" import { processDeclarativeChild } from "../utils/process-declarative-child.js" @@ -22,79 +25,25 @@ import { } from "../utils/tooltip-handlers.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. @@ -168,39 +117,17 @@ export const line = { }) .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 { orderedChildren, buckets } = sortChildrenByLayer( + processedChildrenArray, + { + seriesFlag: "isLine", + includeBrush: true, + }, + ) - 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)) + const processedChildren = orderedChildren.map((child) => + typeof child === "function" ? child(context) : child, + ) return html`
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 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/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-renderer.js b/packages/charts/src/utils/cartesian-renderer.js new file mode 100644 index 00000000..675a0d1d --- /dev/null +++ b/packages/charts/src/utils/cartesian-renderer.js @@ -0,0 +1,311 @@ +/* eslint-disable no-magic-numbers */ +import { isMultiSeries } from "./data-utils.js" + +const DEFAULT_COLORS = ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"] +const EXCLUDED_AXIS_KEYS = new Set(["name", "x", "date"]) + +/** + * 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 = [] + + if (entity.showGrid !== false) { + children.push( + chartApi.CartesianGrid({ stroke: "#eee", strokeDasharray: "5 5" }), + ) + } + + const xAxisDataKey = resolveXAxisDataKey(entity) + children.push(chartApi.XAxis({ dataKey: xAxisDataKey })) + children.push(chartApi.YAxis({ width: "auto" })) + + const dataKeys = providedDataKeys?.length + ? providedDataKeys + : resolveDataKeys(entity.data) + + const colors = entity.colors || DEFAULT_COLORS + 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: xAxisDataKey, + 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 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 (child[seriesFlag]) 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 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" +} + +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"] +} + +function getRenderMethod(seriesType) { + return seriesType === "area" ? "renderAreaChart" : "renderLineChart" +} From f14956bc751b31613935997d377f1601d6160a01 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 6 Mar 2026 10:38:51 +0100 Subject: [PATCH 02/34] refactor: move chart core to src root and clarify naming --- packages/charts/src/chart-core.js | 60 +++++++++++++++++++++++++++ packages/charts/src/index.js | 67 +++++++++++++++++++++---------- 2 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 packages/charts/src/chart-core.js diff --git a/packages/charts/src/chart-core.js b/packages/charts/src/chart-core.js new file mode 100644 index 00000000..e58b7dab --- /dev/null +++ b/packages/charts/src/chart-core.js @@ -0,0 +1,60 @@ +import { area } from "./cartesian/area.js" +import { bar } from "./cartesian/bar.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), + 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/index.js b/packages/charts/src/index.js index e1a13fd1..489a9457 100644 --- a/packages/charts/src/index.js +++ b/packages/charts/src/index.js @@ -1,5 +1,6 @@ import { svg } from "@inglorious/web" +import { coreCharts } from "./chart-core.js" import * as handlers from "./handlers.js" import { render } from "./template.js" import { extractDataKeysFromChildren } from "./utils/extract-data-keys.js" @@ -9,6 +10,7 @@ export { withRealtime } from "./realtime/with-realtime.js" export { streamSlide } from "./utils/stream-slide.js" // Export chart types for config style +export { coreCharts } from "./chart-core.js" export { areaChart, barChart, @@ -19,12 +21,13 @@ export { export const chart = { ...handlers, render, + core: coreCharts, // Chart Delegators - renderLineChart: createDelegator("line"), - renderAreaChart: createDelegator("area"), - renderBarChart: createDelegator("bar"), - renderPieChart: createDelegator("pie"), + renderLineChart: renderByChartType("line"), + renderAreaChart: renderByChartType("area"), + renderBarChart: renderByChartType("bar"), + renderPieChart: renderByChartType("pie"), // Component Renderers (Abstracted) renderLine: createComponentRenderer("renderLine", "line"), @@ -34,13 +37,31 @@ export const chart = { 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"), + // Deferred Renderers (resolved once, then executed by chart runtime) + renderCartesianGrid: (entity, props = {}, api) => + createDeferredRenderer( + entity, + api, + "renderCartesianGrid", + props.config, + props.chartType, + ), + renderXAxis: (entity, props = {}, api) => + createDeferredRenderer( + entity, + api, + "renderXAxis", + props.config, + props.chartType, + ), + renderBrush: (entity, props = {}, api) => + createDeferredRenderer( + entity, + api, + "renderBrush", + props.config, + props.chartType, + ), // Declarative Helpers for Composition Style (return intention objects) // The parent (renderLineChart, etc) processes these objects and "stamps" them with entity and api @@ -178,7 +199,7 @@ function createInstance(entity, api, isInline = false) { return instance } -function createDelegator(typeKey) { +function renderByChartType(typeKey) { const firstCharIndex = 0 const restStartIndex = 1 const firstChar = typeKey.charAt(firstCharIndex) @@ -194,15 +215,19 @@ function createDelegator(typeKey) { } } -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 createDeferredRenderer( + entity, + api, + methodName, + config = {}, + chartTypeName = null, +) { + if (!entity) return renderEmptyTemplate() + const resolvedTypeName = chartTypeName || entity.type + const chartType = api.getType(resolvedTypeName) + return chartType?.[methodName] + ? chartType[methodName](entity, { config }, api) + : renderEmptyTemplate() } function createComponentRenderer(methodName, typeOverride = null) { From baf30b0e0dcf459d5c9acccace965b25893cef93 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 6 Mar 2026 11:17:23 +0100 Subject: [PATCH 03/34] refactor: remove deferred renderers from chart facade for clearer and simpler rendering flow --- packages/charts/src/index.js | 81 ++++++++++++------------------------ 1 file changed, 26 insertions(+), 55 deletions(-) diff --git a/packages/charts/src/index.js b/packages/charts/src/index.js index 489a9457..cbd9c5ab 100644 --- a/packages/charts/src/index.js +++ b/packages/charts/src/index.js @@ -37,31 +37,14 @@ export const chart = { renderYAxis: createComponentRenderer("renderYAxis"), renderTooltip: createComponentRenderer("renderTooltip"), - // Deferred Renderers (resolved once, then executed by chart runtime) - renderCartesianGrid: (entity, props = {}, api) => - createDeferredRenderer( - entity, - api, - "renderCartesianGrid", - props.config, - props.chartType, - ), - renderXAxis: (entity, props = {}, api) => - createDeferredRenderer( - entity, - api, - "renderXAxis", - props.config, - props.chartType, - ), - renderBrush: (entity, props = {}, api) => - createDeferredRenderer( - entity, - api, - "renderBrush", - props.config, - props.chartType, - ), + // Component Renderers with optional chartType override from props + renderCartesianGrid: createComponentRenderer( + "renderCartesianGrid", + null, + true, + ), + renderXAxis: createComponentRenderer("renderXAxis", null, true), + renderBrush: createComponentRenderer("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 @@ -215,25 +198,17 @@ function renderByChartType(typeKey) { } } -function createDeferredRenderer( - entity, - api, +function createComponentRenderer( methodName, - config = {}, - chartTypeName = null, + typeOverride = null, + preferChartTypeProp = false, ) { - if (!entity) return renderEmptyTemplate() - const resolvedTypeName = chartTypeName || entity.type - const chartType = api.getType(resolvedTypeName) - return chartType?.[methodName] - ? chartType[methodName](entity, { config }, api) - : renderEmptyTemplate() -} - -function createComponentRenderer(methodName, typeOverride = null) { - return function renderComponent(entity, { config = {} }, api) { + return function renderComponent(entity, props = {}, api) { if (!entity) return renderEmptyTemplate() - const type = api.getType(typeOverride || entity.type) + 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() @@ -251,42 +226,38 @@ function renderEmptyTemplate() { return svg`` } -function renderEmptyLazyTemplate() { - return renderEmptyTemplate -} - function getEmptyInstance() { return { renderLineChart: renderEmptyTemplate, renderAreaChart: renderEmptyTemplate, renderBarChart: renderEmptyTemplate, renderPieChart: renderEmptyTemplate, - renderCartesianGrid: renderEmptyLazyTemplate, - renderXAxis: renderEmptyLazyTemplate, + renderCartesianGrid: renderEmptyTemplate, + renderXAxis: renderEmptyTemplate, renderYAxis: renderEmptyTemplate, - renderLegend: renderEmptyLazyTemplate, + renderLegend: renderEmptyTemplate, renderLine: renderEmptyTemplate, renderArea: renderEmptyTemplate, renderBar: renderEmptyTemplate, renderPie: renderEmptyTemplate, - renderDots: renderEmptyLazyTemplate, + renderDots: renderEmptyTemplate, renderTooltip: renderEmptyTemplate, - renderBrush: renderEmptyLazyTemplate, + renderBrush: renderEmptyTemplate, // Composition Style LineChart: renderEmptyTemplate, AreaChart: renderEmptyTemplate, BarChart: renderEmptyTemplate, PieChart: renderEmptyTemplate, - CartesianGrid: renderEmptyLazyTemplate, - XAxis: renderEmptyLazyTemplate, + CartesianGrid: renderEmptyTemplate, + XAxis: renderEmptyTemplate, YAxis: renderEmptyTemplate, Line: renderEmptyTemplate, Area: renderEmptyTemplate, Bar: renderEmptyTemplate, Pie: renderEmptyTemplate, - Dots: renderEmptyLazyTemplate, + Dots: renderEmptyTemplate, Tooltip: renderEmptyTemplate, - Brush: renderEmptyLazyTemplate, - Legend: renderEmptyLazyTemplate, + Brush: renderEmptyTemplate, + Legend: renderEmptyTemplate, } } From 31df5a283fae5ae24fb3041546e083cc38fa3447 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 6 Mar 2026 13:10:39 +0100 Subject: [PATCH 04/34] refactor: split index logic into core/* and rename internal helpers --- packages/charts/src/{ => core}/chart-core.js | 12 +- .../charts/src/core/create-chart-instance.js | 90 +++++++ .../charts/src/core/declarative-children.js | 37 +++ packages/charts/src/core/empty-instance.js | 36 +++ packages/charts/src/core/render-dispatch.js | 45 ++++ packages/charts/src/index.js | 246 ++---------------- 6 files changed, 242 insertions(+), 224 deletions(-) rename packages/charts/src/{ => core}/chart-core.js (84%) create mode 100644 packages/charts/src/core/create-chart-instance.js create mode 100644 packages/charts/src/core/declarative-children.js create mode 100644 packages/charts/src/core/empty-instance.js create mode 100644 packages/charts/src/core/render-dispatch.js diff --git a/packages/charts/src/chart-core.js b/packages/charts/src/core/chart-core.js similarity index 84% rename from packages/charts/src/chart-core.js rename to packages/charts/src/core/chart-core.js index e58b7dab..1e13ccc9 100644 --- a/packages/charts/src/chart-core.js +++ b/packages/charts/src/core/chart-core.js @@ -1,9 +1,9 @@ -import { area } from "./cartesian/area.js" -import { bar } from "./cartesian/bar.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" +import { area } from "../cartesian/area.js" +import { bar } from "../cartesian/bar.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 = () => {} 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..54cd64a7 --- /dev/null +++ b/packages/charts/src/core/create-chart-instance.js @@ -0,0 +1,90 @@ +import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js" +import { + attachInstancePascalAliases, + createDeclarativeChildren, + createInstanceRenderAliases, +} from "./declarative-children.js" +import { renderWithEntityTypeMethod } from "./render-dispatch.js" + +export function createChartInstance(entity, api, isInline = false) { + let currentEntity = entity + + const buildChartRenderFactory = + (chartType, renderMethod, useStandardSignature = false) => + (firstArg = {}, secondArg = []) => { + const isLegacySignature = !useStandardSignature && Array.isArray(firstArg) + const config = isLegacySignature ? secondArg || {} : firstArg + const children = isLegacySignature ? firstArg : secondArg + + 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), + dataKeys: + chartType !== "pie" + ? config.dataKeys || extractDataKeysFromChildren(children) + : undefined, + } + + return renderWithEntityTypeMethod( + currentEntity, + renderMethod, + { + children: Array.isArray(children) ? children : [children], + config: finalConfig, + }, + api, + ) + } + + const declarativeChildren = createDeclarativeChildren() + + const instance = { + LineChart: buildChartRenderFactory("line", "renderLineChart", true), + AreaChart: buildChartRenderFactory("area", "renderAreaChart", true), + BarChart: buildChartRenderFactory("bar", "renderBarChart", true), + PieChart: buildChartRenderFactory("pie", "renderPieChart", true), + + ...declarativeChildren, + + renderLineChart: buildChartRenderFactory("line", "renderLineChart", false), + renderAreaChart: buildChartRenderFactory("area", "renderAreaChart", false), + renderBarChart: buildChartRenderFactory("bar", "renderBarChart", false), + renderPieChart: buildChartRenderFactory("pie", "renderPieChart", false), + + ...createInstanceRenderAliases(declarativeChildren), + } + + return attachInstancePascalAliases(instance) +} + +export function createInlineChartInstance(api, tempEntity, initializeEntity) { + const entity = tempEntity || { + id: `__temp_${Date.now()}`, + type: "line", + data: [], + } + + const preserveShowTooltip = + tempEntity?.showTooltip !== undefined ? tempEntity.showTooltip : undefined + + initializeEntity(entity) + + if (preserveShowTooltip !== undefined) { + entity.showTooltip = preserveShowTooltip + } + + return createChartInstance(entity, api, true) +} diff --git a/packages/charts/src/core/declarative-children.js b/packages/charts/src/core/declarative-children.js new file mode 100644 index 00000000..f60a0540 --- /dev/null +++ b/packages/charts/src/core/declarative-children.js @@ -0,0 +1,37 @@ +export function createDeclarativeChildren() { + return { + 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 }), + } +} + +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 +} diff --git a/packages/charts/src/core/empty-instance.js b/packages/charts/src/core/empty-instance.js new file mode 100644 index 00000000..0d81888c --- /dev/null +++ b/packages/charts/src/core/empty-instance.js @@ -0,0 +1,36 @@ +import { renderEmptyTemplate } from "./render-dispatch.js" + +export function getEmptyChartInstance() { + return { + renderLineChart: renderEmptyTemplate, + renderAreaChart: renderEmptyTemplate, + renderBarChart: renderEmptyTemplate, + renderPieChart: renderEmptyTemplate, + renderCartesianGrid: renderEmptyTemplate, + renderXAxis: renderEmptyTemplate, + renderYAxis: renderEmptyTemplate, + renderLegend: renderEmptyTemplate, + renderLine: renderEmptyTemplate, + renderArea: renderEmptyTemplate, + renderBar: renderEmptyTemplate, + renderPie: renderEmptyTemplate, + renderDots: renderEmptyTemplate, + renderTooltip: renderEmptyTemplate, + renderBrush: renderEmptyTemplate, + LineChart: renderEmptyTemplate, + AreaChart: renderEmptyTemplate, + BarChart: renderEmptyTemplate, + PieChart: renderEmptyTemplate, + CartesianGrid: renderEmptyTemplate, + XAxis: renderEmptyTemplate, + YAxis: renderEmptyTemplate, + Line: renderEmptyTemplate, + Area: renderEmptyTemplate, + Bar: renderEmptyTemplate, + Pie: renderEmptyTemplate, + Dots: renderEmptyTemplate, + Tooltip: renderEmptyTemplate, + Brush: renderEmptyTemplate, + Legend: 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..60ddbc3a --- /dev/null +++ b/packages/charts/src/core/render-dispatch.js @@ -0,0 +1,45 @@ +import { svg } from "@inglorious/web" + +export function renderByChartType(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 renderUsingType(entity, params, api) { + if (!entity) return renderEmptyTemplate() + const chartType = api.getType(typeKey) + return chartType?.[methodName] + ? chartType[methodName](entity, params, api) + : renderEmptyTemplate() + } +} + +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 renderEmptyTemplate() { + return svg`` +} diff --git a/packages/charts/src/index.js b/packages/charts/src/index.js index cbd9c5ab..309f01c2 100644 --- a/packages/charts/src/index.js +++ b/packages/charts/src/index.js @@ -1,16 +1,23 @@ -import { svg } from "@inglorious/web" - -import { coreCharts } from "./chart-core.js" +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, + renderByChartType, +} 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" +import { render } from "./template.js" export { streamSlide } from "./utils/stream-slide.js" // Export chart types for config style -export { coreCharts } from "./chart-core.js" +export { coreCharts } from "./core/chart-core.js" export { areaChart, barChart, @@ -18,6 +25,8 @@ export { pieChart, } from "./utils/chart-utils.js" +const declarativeChildren = createDeclarativeChildren() + export const chart = { ...handlers, render, @@ -30,234 +39,35 @@ export const chart = { renderPieChart: renderByChartType("pie"), // 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"), + 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: createComponentRenderer( + renderCartesianGrid: buildComponentRenderer( "renderCartesianGrid", null, true, ), - renderXAxis: createComponentRenderer("renderXAxis", null, true), - renderBrush: createComponentRenderer("renderBrush", 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 renderByChartType(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 createComponentRenderer( - 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() - } -} - -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 getEmptyInstance() { - return { - renderLineChart: renderEmptyTemplate, - renderAreaChart: renderEmptyTemplate, - renderBarChart: renderEmptyTemplate, - renderPieChart: renderEmptyTemplate, - renderCartesianGrid: renderEmptyTemplate, - renderXAxis: renderEmptyTemplate, - renderYAxis: renderEmptyTemplate, - renderLegend: renderEmptyTemplate, - renderLine: renderEmptyTemplate, - renderArea: renderEmptyTemplate, - renderBar: renderEmptyTemplate, - renderPie: renderEmptyTemplate, - renderDots: renderEmptyTemplate, - renderTooltip: renderEmptyTemplate, - renderBrush: renderEmptyTemplate, - // Composition Style - LineChart: renderEmptyTemplate, - AreaChart: renderEmptyTemplate, - BarChart: renderEmptyTemplate, - PieChart: renderEmptyTemplate, - CartesianGrid: renderEmptyTemplate, - XAxis: renderEmptyTemplate, - YAxis: renderEmptyTemplate, - Line: renderEmptyTemplate, - Area: renderEmptyTemplate, - Bar: renderEmptyTemplate, - Pie: renderEmptyTemplate, - Dots: renderEmptyTemplate, - Tooltip: renderEmptyTemplate, - Brush: renderEmptyTemplate, - Legend: renderEmptyTemplate, - } + createInstance: createChartInstance, } From c8682f9e7c9539e90082746f235f8e1368963c19 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 6 Mar 2026 17:04:08 +0100 Subject: [PATCH 05/34] refactor: simplify core method maps and make inline defaults safer --- .../charts/src/core/chart-type-methods.js | 6 + .../charts/src/core/create-chart-instance.js | 184 +++++++++++++----- .../charts/src/core/declarative-children.js | 34 ++-- packages/charts/src/core/empty-instance.js | 56 ++++-- packages/charts/src/core/render-dispatch.js | 15 +- 5 files changed, 205 insertions(+), 90 deletions(-) create mode 100644 packages/charts/src/core/chart-type-methods.js 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 index 54cd64a7..848e00a6 100644 --- a/packages/charts/src/core/create-chart-instance.js +++ b/packages/charts/src/core/create-chart-instance.js @@ -1,4 +1,5 @@ import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js" +import { CHART_TYPE_METHODS } from "./chart-type-methods.js" import { attachInstancePascalAliases, createDeclarativeChildren, @@ -9,60 +10,32 @@ import { renderWithEntityTypeMethod } from "./render-dispatch.js" export function createChartInstance(entity, api, isInline = false) { let currentEntity = entity - const buildChartRenderFactory = - (chartType, renderMethod, useStandardSignature = false) => - (firstArg = {}, secondArg = []) => { - const isLegacySignature = !useStandardSignature && Array.isArray(firstArg) - const config = isLegacySignature ? secondArg || {} : firstArg - const children = isLegacySignature ? firstArg : secondArg - - 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), - dataKeys: - chartType !== "pie" - ? config.dataKeys || extractDataKeysFromChildren(children) - : undefined, - } + const readCurrentEntity = () => currentEntity + const writeCurrentEntity = (nextEntity) => { + currentEntity = nextEntity + } - return renderWithEntityTypeMethod( - currentEntity, - renderMethod, - { - children: Array.isArray(children) ? children : [children], - config: finalConfig, - }, - api, - ) - } + const buildChartRenderMethod = isInline + ? createInlineMethodBuilder({ + readCurrentEntity, + writeCurrentEntity, + api, + }) + : createEntityMethodBuilder({ readCurrentEntity, api }) const declarativeChildren = createDeclarativeChildren() + const standardChartMethods = buildChartMethodMap(buildChartRenderMethod, true) + const compatibilityChartMethods = buildChartMethodMap( + buildChartRenderMethod, + false, + ) const instance = { - LineChart: buildChartRenderFactory("line", "renderLineChart", true), - AreaChart: buildChartRenderFactory("area", "renderAreaChart", true), - BarChart: buildChartRenderFactory("bar", "renderBarChart", true), - PieChart: buildChartRenderFactory("pie", "renderPieChart", true), + ...standardChartMethods, ...declarativeChildren, - renderLineChart: buildChartRenderFactory("line", "renderLineChart", false), - renderAreaChart: buildChartRenderFactory("area", "renderAreaChart", false), - renderBarChart: buildChartRenderFactory("bar", "renderBarChart", false), - renderPieChart: buildChartRenderFactory("pie", "renderPieChart", false), + ...compatibilityChartMethods, ...createInstanceRenderAliases(declarativeChildren), } @@ -77,14 +50,123 @@ export function createInlineChartInstance(api, tempEntity, initializeEntity) { data: [], } - const preserveShowTooltip = - tempEntity?.showTooltip !== undefined ? tempEntity.showTooltip : undefined + const protectedProps = ["showTooltip", "width", "height", "type", "data"] + const preserved = protectedProps.reduce((acc, prop) => { + if (entity[prop] !== undefined) acc[prop] = entity[prop] + return acc + }, {}) initializeEntity(entity) + Object.assign(entity, preserved) + + return createChartInstance(entity, api, true) +} + +function buildChartMethodMap(buildChartRenderFactory, useStandardSignature) { + return Object.fromEntries( + CHART_TYPE_METHODS.map(({ type, suffix }) => { + const methodName = `render${suffix}Chart` + const exposedName = useStandardSignature ? `${suffix}Chart` : methodName + return [exposedName, buildChartRenderFactory(type, methodName, useStandardSignature)] + }), + ) +} + +function createEntityMethodBuilder({ readCurrentEntity, api }) { + return (chartType, renderMethod, useStandardSignature = false) => + (firstArg = {}, secondArg = []) => { + const { config, children } = resolveRenderArgs( + firstArg, + secondArg, + useStandardSignature, + ) + + const currentEntity = readCurrentEntity() + const finalConfig = buildFinalConfig({ + chartType, + config, + children, + dataFromEntity: currentEntity.data, + shouldFallbackToEntityData: true, + }) - if (preserveShowTooltip !== undefined) { - entity.showTooltip = preserveShowTooltip + return renderWithEntityTypeMethod( + currentEntity, + renderMethod, + { + children: Array.isArray(children) ? children : [children], + config: finalConfig, + }, + api, + ) + } +} + +function createInlineMethodBuilder({ readCurrentEntity, writeCurrentEntity, api }) { + return (chartType, renderMethod, useStandardSignature = false) => + (firstArg = {}, secondArg = []) => { + const { config, children } = resolveRenderArgs( + firstArg, + secondArg, + useStandardSignature, + ) + + const currentEntity = readCurrentEntity() + const nextEntity = buildInlineEntity(currentEntity, chartType, config) + writeCurrentEntity(nextEntity) + + const finalConfig = buildFinalConfig({ + chartType, + config, + children, + dataFromEntity: nextEntity.data, + shouldFallbackToEntityData: false, + }) + + return renderWithEntityTypeMethod( + nextEntity, + renderMethod, + { + children: Array.isArray(children) ? children : [children], + config: finalConfig, + }, + api, + ) + } +} + +function resolveRenderArgs(firstArg, secondArg, useStandardSignature) { + const isLegacySignature = !useStandardSignature && Array.isArray(firstArg) + return { + config: isLegacySignature ? secondArg || {} : firstArg, + children: isLegacySignature ? firstArg : secondArg, } +} - return createChartInstance(entity, api, true) +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, + } } diff --git a/packages/charts/src/core/declarative-children.js b/packages/charts/src/core/declarative-children.js index f60a0540..06ab2b2d 100644 --- a/packages/charts/src/core/declarative-children.js +++ b/packages/charts/src/core/declarative-children.js @@ -1,17 +1,21 @@ +export const DECLARATIVE_CHILD_NAMES = [ + "XAxis", + "YAxis", + "Line", + "Area", + "Bar", + "Pie", + "CartesianGrid", + "Tooltip", + "Brush", + "Dots", + "Legend", +] + export function createDeclarativeChildren() { - return { - 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 }), - } + return Object.fromEntries( + DECLARATIVE_CHILD_NAMES.map((name) => [name, buildDeclarativeChild(name)]), + ) } export function createInstanceRenderAliases(declarativeChildren) { @@ -35,3 +39,7 @@ export function attachInstancePascalAliases(instance) { 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 index 0d81888c..e1998042 100644 --- a/packages/charts/src/core/empty-instance.js +++ b/packages/charts/src/core/empty-instance.js @@ -1,36 +1,50 @@ +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, + ...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 { - renderLineChart: renderEmptyTemplate, - renderAreaChart: renderEmptyTemplate, - renderBarChart: renderEmptyTemplate, - renderPieChart: renderEmptyTemplate, renderCartesianGrid: renderEmptyTemplate, renderXAxis: renderEmptyTemplate, renderYAxis: renderEmptyTemplate, - renderLegend: renderEmptyTemplate, renderLine: renderEmptyTemplate, renderArea: renderEmptyTemplate, renderBar: renderEmptyTemplate, renderPie: renderEmptyTemplate, - renderDots: renderEmptyTemplate, renderTooltip: renderEmptyTemplate, renderBrush: renderEmptyTemplate, - LineChart: renderEmptyTemplate, - AreaChart: renderEmptyTemplate, - BarChart: renderEmptyTemplate, - PieChart: renderEmptyTemplate, - CartesianGrid: renderEmptyTemplate, - XAxis: renderEmptyTemplate, - YAxis: renderEmptyTemplate, - Line: renderEmptyTemplate, - Area: renderEmptyTemplate, - Bar: renderEmptyTemplate, - Pie: renderEmptyTemplate, - Dots: renderEmptyTemplate, - Tooltip: renderEmptyTemplate, - Brush: renderEmptyTemplate, - Legend: renderEmptyTemplate, + renderDots: renderEmptyTemplate, + renderLegend: renderEmptyTemplate, } } diff --git a/packages/charts/src/core/render-dispatch.js b/packages/charts/src/core/render-dispatch.js index 60ddbc3a..0bc99359 100644 --- a/packages/charts/src/core/render-dispatch.js +++ b/packages/charts/src/core/render-dispatch.js @@ -1,11 +1,10 @@ import { svg } from "@inglorious/web" +const FIRST_CHAR_INDEX = 0 +const REST_START_INDEX = 1 + export function renderByChartType(typeKey) { - const firstCharIndex = 0 - const restStartIndex = 1 - const firstChar = typeKey.charAt(firstCharIndex) - const rest = typeKey.slice(restStartIndex) - const methodName = `render${firstChar.toUpperCase() + rest}Chart` + const methodName = `render${capitalize(typeKey)}Chart` return function renderUsingType(entity, params, api) { if (!entity) return renderEmptyTemplate() @@ -43,3 +42,9 @@ export function 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) + : "" +} From 1ade08fb600102fd730fbdc4b2f36beb81487636 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 6 Mar 2026 17:04:35 +0100 Subject: [PATCH 06/34] adjust lint --- .../charts/src/core/create-chart-instance.js | 22 +++++++++++++------ packages/charts/src/core/empty-instance.js | 9 ++++---- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/charts/src/core/create-chart-instance.js b/packages/charts/src/core/create-chart-instance.js index 848e00a6..8bf90e16 100644 --- a/packages/charts/src/core/create-chart-instance.js +++ b/packages/charts/src/core/create-chart-instance.js @@ -17,10 +17,10 @@ export function createChartInstance(entity, api, isInline = false) { const buildChartRenderMethod = isInline ? createInlineMethodBuilder({ - readCurrentEntity, - writeCurrentEntity, - api, - }) + readCurrentEntity, + writeCurrentEntity, + api, + }) : createEntityMethodBuilder({ readCurrentEntity, api }) const declarativeChildren = createDeclarativeChildren() @@ -67,7 +67,10 @@ function buildChartMethodMap(buildChartRenderFactory, useStandardSignature) { CHART_TYPE_METHODS.map(({ type, suffix }) => { const methodName = `render${suffix}Chart` const exposedName = useStandardSignature ? `${suffix}Chart` : methodName - return [exposedName, buildChartRenderFactory(type, methodName, useStandardSignature)] + return [ + exposedName, + buildChartRenderFactory(type, methodName, useStandardSignature), + ] }), ) } @@ -102,7 +105,11 @@ function createEntityMethodBuilder({ readCurrentEntity, api }) { } } -function createInlineMethodBuilder({ readCurrentEntity, writeCurrentEntity, api }) { +function createInlineMethodBuilder({ + readCurrentEntity, + writeCurrentEntity, + api, +}) { return (chartType, renderMethod, useStandardSignature = false) => (firstArg = {}, secondArg = []) => { const { config, children } = resolveRenderArgs( @@ -163,7 +170,8 @@ function buildFinalConfig({ }) { return { ...config, - data: config.data || (shouldFallbackToEntityData ? dataFromEntity : undefined), + data: + config.data || (shouldFallbackToEntityData ? dataFromEntity : undefined), dataKeys: chartType !== "pie" ? config.dataKeys || extractDataKeysFromChildren(children) diff --git a/packages/charts/src/core/empty-instance.js b/packages/charts/src/core/empty-instance.js index e1998042..4f7e17f8 100644 --- a/packages/charts/src/core/empty-instance.js +++ b/packages/charts/src/core/empty-instance.js @@ -1,7 +1,5 @@ import { CHART_TYPE_METHODS } from "./chart-type-methods.js" -import { - DECLARATIVE_CHILD_NAMES, -} from "./declarative-children.js" +import { DECLARATIVE_CHILD_NAMES } from "./declarative-children.js" import { renderEmptyTemplate } from "./render-dispatch.js" export function getEmptyChartInstance() { @@ -29,7 +27,10 @@ function buildNoopChartMethods() { function buildNoopDeclarativeMethods() { return Object.fromEntries( - DECLARATIVE_CHILD_NAMES.map((methodName) => [methodName, renderEmptyTemplate]), + DECLARATIVE_CHILD_NAMES.map((methodName) => [ + methodName, + renderEmptyTemplate, + ]), ) } From ef8c2581ab7b4c0a1de79bf382c1b984a07bf20d Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 9 Mar 2026 09:57:08 +0100 Subject: [PATCH 07/34] refactor: clarify instance renderer naming and isolate legacy adapter flow --- .../charts/src/core/create-chart-instance.js | 118 +++++++++--------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/packages/charts/src/core/create-chart-instance.js b/packages/charts/src/core/create-chart-instance.js index 8bf90e16..39d5b931 100644 --- a/packages/charts/src/core/create-chart-instance.js +++ b/packages/charts/src/core/create-chart-instance.js @@ -7,6 +7,14 @@ import { } from "./declarative-children.js" import { renderWithEntityTypeMethod } from "./render-dispatch.js" +const INLINE_PROTECTED_PROPS = [ + "showTooltip", + "width", + "height", + "type", + "data", +] + export function createChartInstance(entity, api, isInline = false) { let currentEntity = entity @@ -15,27 +23,25 @@ export function createChartInstance(entity, api, isInline = false) { currentEntity = nextEntity } - const buildChartRenderMethod = isInline - ? createInlineMethodBuilder({ + const buildRenderer = isInline + ? createSelfManagedRenderer({ readCurrentEntity, writeCurrentEntity, api, }) - : createEntityMethodBuilder({ readCurrentEntity, api }) + : createStoreManagedRenderer({ readCurrentEntity, api }) + const legacyAdapter = wrapAsLegacyAdapter(buildRenderer) const declarativeChildren = createDeclarativeChildren() - const standardChartMethods = buildChartMethodMap(buildChartRenderMethod, true) - const compatibilityChartMethods = buildChartMethodMap( - buildChartRenderMethod, - false, - ) + const standardMethods = mapCatalogToMethods(buildRenderer, "standard") + const legacyMethods = mapCatalogToMethods(legacyAdapter, "legacy") const instance = { - ...standardChartMethods, + ...standardMethods, ...declarativeChildren, - ...compatibilityChartMethods, + ...legacyMethods, ...createInstanceRenderAliases(declarativeChildren), } @@ -50,11 +56,7 @@ export function createInlineChartInstance(api, tempEntity, initializeEntity) { data: [], } - const protectedProps = ["showTooltip", "width", "height", "type", "data"] - const preserved = protectedProps.reduce((acc, prop) => { - if (entity[prop] !== undefined) acc[prop] = entity[prop] - return acc - }, {}) + const preserved = pickDefinedProps(entity, INLINE_PROTECTED_PROPS) initializeEntity(entity) Object.assign(entity, preserved) @@ -62,28 +64,19 @@ export function createInlineChartInstance(api, tempEntity, initializeEntity) { return createChartInstance(entity, api, true) } -function buildChartMethodMap(buildChartRenderFactory, useStandardSignature) { +function mapCatalogToMethods(buildRenderMethod, mode) { return Object.fromEntries( CHART_TYPE_METHODS.map(({ type, suffix }) => { const methodName = `render${suffix}Chart` - const exposedName = useStandardSignature ? `${suffix}Chart` : methodName - return [ - exposedName, - buildChartRenderFactory(type, methodName, useStandardSignature), - ] + const exposedName = mode === "standard" ? `${suffix}Chart` : methodName + return [exposedName, buildRenderMethod(type, methodName)] }), ) } -function createEntityMethodBuilder({ readCurrentEntity, api }) { - return (chartType, renderMethod, useStandardSignature = false) => - (firstArg = {}, secondArg = []) => { - const { config, children } = resolveRenderArgs( - firstArg, - secondArg, - useStandardSignature, - ) - +function createStoreManagedRenderer({ readCurrentEntity, api }) { + return (chartType, renderMethod) => + (config = {}, children = []) => { const currentEntity = readCurrentEntity() const finalConfig = buildFinalConfig({ chartType, @@ -93,31 +86,23 @@ function createEntityMethodBuilder({ readCurrentEntity, api }) { shouldFallbackToEntityData: true, }) - return renderWithEntityTypeMethod( - currentEntity, + return dispatchRender({ + entity: currentEntity, renderMethod, - { - children: Array.isArray(children) ? children : [children], - config: finalConfig, - }, + children, + finalConfig, api, - ) + }) } } -function createInlineMethodBuilder({ +function createSelfManagedRenderer({ readCurrentEntity, writeCurrentEntity, api, }) { - return (chartType, renderMethod, useStandardSignature = false) => - (firstArg = {}, secondArg = []) => { - const { config, children } = resolveRenderArgs( - firstArg, - secondArg, - useStandardSignature, - ) - + return (chartType, renderMethod) => + (config = {}, children = []) => { const currentEntity = readCurrentEntity() const nextEntity = buildInlineEntity(currentEntity, chartType, config) writeCurrentEntity(nextEntity) @@ -130,24 +115,20 @@ function createInlineMethodBuilder({ shouldFallbackToEntityData: false, }) - return renderWithEntityTypeMethod( - nextEntity, + return dispatchRender({ + entity: nextEntity, renderMethod, - { - children: Array.isArray(children) ? children : [children], - config: finalConfig, - }, + children, + finalConfig, api, - ) + }) } } -function resolveRenderArgs(firstArg, secondArg, useStandardSignature) { - const isLegacySignature = !useStandardSignature && Array.isArray(firstArg) - return { - config: isLegacySignature ? secondArg || {} : firstArg, - children: isLegacySignature ? firstArg : secondArg, - } +function wrapAsLegacyAdapter(buildStandardMethod) { + return (chartType, renderMethod) => + (children = [], config = {}) => + buildStandardMethod(chartType, renderMethod)(config, children) } function buildInlineEntity(currentEntity, chartType, config) { @@ -178,3 +159,22 @@ function buildFinalConfig({ : undefined, } } + +function dispatchRender({ entity, renderMethod, children, finalConfig, api }) { + return renderWithEntityTypeMethod( + entity, + renderMethod, + { + children: Array.isArray(children) ? children : [children], + config: finalConfig, + }, + api, + ) +} + +function pickDefinedProps(source, props) { + return props.reduce((acc, prop) => { + if (source[prop] !== undefined) acc[prop] = source[prop] + return acc + }, {}) +} From af46588719c79731f732efbb29dca7a172c2a73c Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 9 Mar 2026 10:48:19 +0100 Subject: [PATCH 08/34] WIP: update render signature flow, tests, and skills lock --- packages/charts/README.md | 14 +- .../charts/src/core/create-chart-instance.js | 46 ++++++- .../src/core/create-chart-instance.test.js | 123 ++++++++++++++++++ skills-lock.json | 2 +- 4 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 packages/charts/src/core/create-chart-instance.test.js 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/core/create-chart-instance.js b/packages/charts/src/core/create-chart-instance.js index 39d5b931..dccdd3a5 100644 --- a/packages/charts/src/core/create-chart-instance.js +++ b/packages/charts/src/core/create-chart-instance.js @@ -14,6 +14,7 @@ const INLINE_PROTECTED_PROPS = [ "type", "data", ] +const warnedLegacyMethods = new Set() export function createChartInstance(entity, api, isInline = false) { let currentEntity = entity @@ -127,8 +128,18 @@ function createSelfManagedRenderer({ function wrapAsLegacyAdapter(buildStandardMethod) { return (chartType, renderMethod) => - (children = [], config = {}) => - buildStandardMethod(chartType, renderMethod)(config, children) + (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) { @@ -178,3 +189,34 @@ function pickDefinedProps(source, props) { 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) +} 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..b8b28143 --- /dev/null +++ b/packages/charts/src/core/create-chart-instance.test.js @@ -0,0 +1,123 @@ +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 children = [{ type: "Line", config: { dataKey: "value" } }] + 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: [], + }) + + warnSpy.mockRestore() + }) +}) 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" } From 6df0710285bf21d7cd05752cce697cc4a304e854 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 9 Mar 2026 15:25:12 +0100 Subject: [PATCH 09/34] refactor: normalize instance render inputs with safe fallbacks --- .../charts/src/core/create-chart-instance.js | 54 ++++++++++++++++--- .../src/core/create-chart-instance.test.js | 54 ++++++++++++++++++- 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/packages/charts/src/core/create-chart-instance.js b/packages/charts/src/core/create-chart-instance.js index dccdd3a5..00c650a6 100644 --- a/packages/charts/src/core/create-chart-instance.js +++ b/packages/charts/src/core/create-chart-instance.js @@ -78,11 +78,16 @@ function mapCatalogToMethods(buildRenderMethod, mode) { function createStoreManagedRenderer({ readCurrentEntity, api }) { return (chartType, renderMethod) => (config = {}, children = []) => { - const currentEntity = readCurrentEntity() - const finalConfig = buildFinalConfig({ + const normalizedArgs = normalizeRenderInputs({ chartType, config, children, + }) + const currentEntity = readCurrentEntity() + const finalConfig = buildFinalConfig({ + chartType, + config: normalizedArgs.config, + children: normalizedArgs.children, dataFromEntity: currentEntity.data, shouldFallbackToEntityData: true, }) @@ -90,7 +95,7 @@ function createStoreManagedRenderer({ readCurrentEntity, api }) { return dispatchRender({ entity: currentEntity, renderMethod, - children, + children: normalizedArgs.children, finalConfig, api, }) @@ -104,14 +109,23 @@ function createSelfManagedRenderer({ }) { return (chartType, renderMethod) => (config = {}, children = []) => { + const normalizedArgs = normalizeRenderInputs({ + chartType, + config, + children, + }) const currentEntity = readCurrentEntity() - const nextEntity = buildInlineEntity(currentEntity, chartType, config) + const nextEntity = buildInlineEntity( + currentEntity, + chartType, + normalizedArgs.config, + ) writeCurrentEntity(nextEntity) const finalConfig = buildFinalConfig({ chartType, - config, - children, + config: normalizedArgs.config, + children: normalizedArgs.children, dataFromEntity: nextEntity.data, shouldFallbackToEntityData: false, }) @@ -119,7 +133,7 @@ function createSelfManagedRenderer({ return dispatchRender({ entity: nextEntity, renderMethod, - children, + children: normalizedArgs.children, finalConfig, api, }) @@ -220,3 +234,29 @@ function isLegacyRenderArgs(firstArg, 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 index b8b28143..335efb8d 100644 --- a/packages/charts/src/core/create-chart-instance.test.js +++ b/packages/charts/src/core/create-chart-instance.test.js @@ -98,7 +98,9 @@ describe("createChartInstance legacy adapter", () => { } const instance = createChartInstance(entity, api) - const children = [{ type: "Line", config: { dataKey: "value" } }] + const lineChild = () => null + lineChild.dataKey = "value" + const children = [lineChild] instance.renderLineChart(children) const chartsWarnings = warnSpy.mock.calls @@ -115,9 +117,57 @@ describe("createChartInstance legacy adapter", () => { expect(renderLineChart.mock.calls[0][1].children).toEqual(children) expect(renderLineChart.mock.calls[0][1].config).toMatchObject({ data: entity.data, - dataKeys: [], + 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: [] }, + }) + }) }) From ff29f8405220a791f77a52fa47ffe78d321aef22 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 9 Mar 2026 16:02:55 +0100 Subject: [PATCH 10/34] refactor: inject type dispatcher to reduce chart instance coupling with api --- .../charts/src/core/create-chart-instance.js | 39 +++++++++++-------- packages/charts/src/core/render-dispatch.js | 6 +++ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/charts/src/core/create-chart-instance.js b/packages/charts/src/core/create-chart-instance.js index 00c650a6..aed838fb 100644 --- a/packages/charts/src/core/create-chart-instance.js +++ b/packages/charts/src/core/create-chart-instance.js @@ -5,7 +5,7 @@ import { createDeclarativeChildren, createInstanceRenderAliases, } from "./declarative-children.js" -import { renderWithEntityTypeMethod } from "./render-dispatch.js" +import { createTypeDispatcher } from "./render-dispatch.js" const INLINE_PROTECTED_PROPS = [ "showTooltip", @@ -18,6 +18,7 @@ const warnedLegacyMethods = new Set() export function createChartInstance(entity, api, isInline = false) { let currentEntity = entity + const dispatchByEntityType = createTypeDispatcher(api) const readCurrentEntity = () => currentEntity const writeCurrentEntity = (nextEntity) => { @@ -28,9 +29,9 @@ export function createChartInstance(entity, api, isInline = false) { ? createSelfManagedRenderer({ readCurrentEntity, writeCurrentEntity, - api, + dispatchByEntityType, }) - : createStoreManagedRenderer({ readCurrentEntity, api }) + : createStoreManagedRenderer({ readCurrentEntity, dispatchByEntityType }) const legacyAdapter = wrapAsLegacyAdapter(buildRenderer) const declarativeChildren = createDeclarativeChildren() @@ -75,7 +76,10 @@ function mapCatalogToMethods(buildRenderMethod, mode) { ) } -function createStoreManagedRenderer({ readCurrentEntity, api }) { +function createStoreManagedRenderer({ + readCurrentEntity, + dispatchByEntityType, +}) { return (chartType, renderMethod) => (config = {}, children = []) => { const normalizedArgs = normalizeRenderInputs({ @@ -97,7 +101,7 @@ function createStoreManagedRenderer({ readCurrentEntity, api }) { renderMethod, children: normalizedArgs.children, finalConfig, - api, + dispatchByEntityType, }) } } @@ -105,7 +109,7 @@ function createStoreManagedRenderer({ readCurrentEntity, api }) { function createSelfManagedRenderer({ readCurrentEntity, writeCurrentEntity, - api, + dispatchByEntityType, }) { return (chartType, renderMethod) => (config = {}, children = []) => { @@ -135,7 +139,7 @@ function createSelfManagedRenderer({ renderMethod, children: normalizedArgs.children, finalConfig, - api, + dispatchByEntityType, }) } } @@ -185,16 +189,17 @@ function buildFinalConfig({ } } -function dispatchRender({ entity, renderMethod, children, finalConfig, api }) { - return renderWithEntityTypeMethod( - entity, - renderMethod, - { - children: Array.isArray(children) ? children : [children], - config: finalConfig, - }, - api, - ) +function dispatchRender({ + entity, + renderMethod, + children, + finalConfig, + dispatchByEntityType, +}) { + return dispatchByEntityType(entity, renderMethod, { + children: Array.isArray(children) ? children : [children], + config: finalConfig, + }) } function pickDefinedProps(source, props) { diff --git a/packages/charts/src/core/render-dispatch.js b/packages/charts/src/core/render-dispatch.js index 0bc99359..7bcccef8 100644 --- a/packages/charts/src/core/render-dispatch.js +++ b/packages/charts/src/core/render-dispatch.js @@ -39,6 +39,12 @@ export function renderWithEntityTypeMethod(entity, methodName, params, api) { : renderEmptyTemplate() } +export function createTypeDispatcher(api) { + return function dispatchByEntityType(entity, methodName, params) { + return renderWithEntityTypeMethod(entity, methodName, params, api) + } +} + export function renderEmptyTemplate() { return svg`` } From 379c36f02ca3eeafc4beb189ee9bf84b9f1bc603 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Tue, 10 Mar 2026 15:59:32 +0100 Subject: [PATCH 11/34] fix: stabilize runtime ids for clipPath and update pie/donut examples --- .../apps/web-charts/src/sections/donut.js | 73 +++++++++++++++++++ examples/apps/web-charts/src/sections/pie.js | 69 ++++++++++++++++++ packages/charts/src/cartesian/line.js | 20 ++++- packages/charts/src/utils/runtime-id.js | 70 ++++++++++++++++++ 4 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/charts/src/utils/runtime-id.js diff --git a/examples/apps/web-charts/src/sections/donut.js b/examples/apps/web-charts/src/sections/donut.js index cef175fe..09833f66 100644 --- a/examples/apps/web-charts/src/sections/donut.js +++ b/examples/apps/web-charts/src/sections/donut.js @@ -2,6 +2,17 @@ import { html } from "@inglorious/web" import { chart } from "@inglorious/charts" export function renderDonutSection(api) { + const inlineDonutDataA = [ + { name: "A", value: 20 }, + { name: "B", value: 35 }, + { name: "C", value: 15 }, + ] + const inlineDonutDataB = [ + { name: "A", value: 10 }, + { name: "B", value: 25 }, + { name: "C", value: 40 }, + ] + return html`
@@ -33,5 +44,67 @@ export function renderDonutSection(api) { )}
+ +
+
+

Donut Chart - Composition (No id #1)

+ ${chart.renderPieChart( + { + type: "donut", + data: inlineDonutDataA, + width: 360, + height: 280, + showTooltip: true, + }, + { + width: 360, + height: 280, + centerText: "Total", + children: [ + chart.Pie({ + dataKey: "value", + nameKey: "name", + cx: "50%", + cy: "50%", + outerRadius: 90, + innerRadius: 54, + label: true, + }), + ], + }, + api, + )} +
+ +
+

Donut Chart - Composition (No id #2)

+ ${chart.renderPieChart( + { + type: "donut", + data: inlineDonutDataB, + width: 360, + height: 280, + showTooltip: true, + }, + { + width: 360, + height: 280, + centerText: "Total", + children: [ + chart.Pie({ + dataKey: "value", + nameKey: "name", + cx: "50%", + cy: "50%", + outerRadius: 90, + innerRadius: 54, + label: true, + }), + ], + }, + api, + )} +
+
` } diff --git a/examples/apps/web-charts/src/sections/pie.js b/examples/apps/web-charts/src/sections/pie.js index 4c835a8e..d1834dd7 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`
@@ -31,5 +42,63 @@ export function renderPieSection(api) { )}
+ +
+
+

Pie Chart - Composition (No id #1)

+ ${chart.renderPieChart( + { + 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.renderPieChart( + { + 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/packages/charts/src/cartesian/line.js b/packages/charts/src/cartesian/line.js index 6a0d6dfe..f1532c96 100644 --- a/packages/charts/src/cartesian/line.js +++ b/packages/charts/src/cartesian/line.js @@ -17,6 +17,7 @@ import { getTransformedData, parseDimension } from "../utils/data-utils.js" import { extractDataKeysFromChildren } from "../utils/extract-data-keys.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 { @@ -58,6 +59,7 @@ export const line = { const entityForBrush = config.originalEntity || entity const entityWithData = { ...entity } + const clipPathId = ensureClipPathId(entityForBrush) // Collect data keys (used for scales and legends) const dataKeysSet = new Set() @@ -104,6 +106,7 @@ export const line = { context.entity = entityWithData context.fullEntity = entityForBrush context.api = api + context.clipPathId = clipPathId // Process children (Grid, Line, XAxis, etc) const processedChildrenArray = ( @@ -144,7 +147,7 @@ export const line = { })} > - + + ${showDots ? line.renderDots(e, { config: { ...config, fill: stroke } }, api)(ctx) : ""} ` @@ -311,7 +314,7 @@ export const line = { if (!data || data.length === 0) return svg`` return svg` - + ${repeat( data, (d, i) => `${dataKey}-${i}`, @@ -377,3 +380,14 @@ export const line = { */ renderBrush: createBrushComponent(), } + +function ensureClipPathId(entity) { + return `chart-clip-${ensureChartRuntimeId(entity)}` +} + +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/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 +} From ce2baf42bcc7f673f4ef7a41c97c835cc9ae6ebb Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Wed, 11 Mar 2026 12:51:11 +0100 Subject: [PATCH 12/34] refactor: allow composition without entity and stabilize inline tooltips --- examples/apps/web-charts/src/sections/line.js | 20 ++- packages/charts/src/cartesian/line.js | 9 +- packages/charts/src/core/render-dispatch.js | 162 +++++++++++++++++- packages/charts/src/utils/tooltip-handlers.js | 26 +++ 4 files changed, 207 insertions(+), 10 deletions(-) diff --git a/examples/apps/web-charts/src/sections/line.js b/examples/apps/web-charts/src/sections/line.js index 656559d5..0c957a7d 100644 --- a/examples/apps/web-charts/src/sections/line.js +++ b/examples/apps/web-charts/src/sections/line.js @@ -1,6 +1,13 @@ import { html } from "@inglorious/web" import { chart } from "@inglorious/charts" +const inlineLineData = [ + { name: "0", value: 40 }, + { name: "1", value: 120 }, + { name: "2", value: 90 }, + { name: "3", value: 160 }, +] + export function renderLineSections(api, status) { const { isRealtimeConfigPaused, isRealtimeCompositionPaused } = status @@ -38,13 +45,12 @@ export function renderLineSections(api, status) {
-

Line Chart - Composition (Padding 0)

+

Line Chart - Composition (No entity)

${chart.renderLineChart( - api.getEntity("salesLineChartCompositionPadding"), { - width: 800, - height: 400, - padding: { top: 10, right: -10, bottom: 10, left: 0 }, + data: inlineLineData, + width: 600, + height: 240, dataKeys: ["value"], children: [ chart.CartesianGrid({ @@ -53,8 +59,8 @@ export function renderLineSections(api, status) { }), chart.XAxis({ dataKey: "name" }), chart.YAxis({ width: "auto" }), - chart.Line({ dataKey: "value", stroke: "#8884d8" }), - chart.Dots({ dataKey: "value", fill: "#8884d8" }), + chart.Line({ dataKey: "value", stroke: "#2563eb" }), + chart.Dots({ dataKey: "value", fill: "#2563eb" }), chart.Tooltip({}), ], }, diff --git a/packages/charts/src/cartesian/line.js b/packages/charts/src/cartesian/line.js index f1532c96..6e5a70ce 100644 --- a/packages/charts/src/cartesian/line.js +++ b/packages/charts/src/cartesian/line.js @@ -58,7 +58,14 @@ export const line = { const { children, ...config } = params const entityForBrush = config.originalEntity || entity - const entityWithData = { ...entity } + const isInlineEntity = entity?.__inline === true + const entityWithData = config.data + ? isInlineEntity + ? Object.assign(entity, { data: config.data }) + : { ...entity, data: config.data } + : isInlineEntity + ? entity + : { ...entity } const clipPathId = ensureClipPathId(entityForBrush) // Collect data keys (used for scales and legends) diff --git a/packages/charts/src/core/render-dispatch.js b/packages/charts/src/core/render-dispatch.js index 7bcccef8..9190298f 100644 --- a/packages/charts/src/core/render-dispatch.js +++ b/packages/charts/src/core/render-dispatch.js @@ -1,13 +1,25 @@ import { svg } from "@inglorious/web" +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(entity, params, api) { - if (!entity) return renderEmptyTemplate() + 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) @@ -54,3 +66,149 @@ function capitalize(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 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/utils/tooltip-handlers.js b/packages/charts/src/utils/tooltip-handlers.js index b50bd2bc..e3396e06 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) @@ -40,6 +42,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, @@ -51,6 +65,12 @@ export function createTooltipHandlers({ entity, api, tooltipData }) { const onMouseLeave = () => { if (!entity.showTooltip) return + if (entity.__inline || typeof api?.notify !== "function") { + handlers.tooltipHide(entity) + api?.notify?.("charts:inlineTooltip") + return + } + api.notify(`#${entity.id}:tooltipHide`) } @@ -83,6 +103,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, From ff5932132109d8e55716e16730033bad919a99d3 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Wed, 11 Mar 2026 15:38:31 +0100 Subject: [PATCH 13/34] refactor: support CartesianGrid stroke customization --- packages/charts/src/component/grid.js | 29 ++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) 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` Date: Thu, 12 Mar 2026 14:16:11 +0100 Subject: [PATCH 14/34] feat: add composed renderer + generic renderChart for mixed cartesian series --- packages/charts/src/cartesian/composed.js | 259 ++++++++++++++++++ packages/charts/src/core/empty-instance.js | 1 + packages/charts/src/core/render-dispatch.js | 78 ++++++ packages/charts/src/index.js | 10 +- .../charts/src/utils/cartesian-renderer.js | 3 +- .../src/utils/process-declarative-child.js | 12 +- 6 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 packages/charts/src/cartesian/composed.js diff --git a/packages/charts/src/cartesian/composed.js b/packages/charts/src/cartesian/composed.js new file mode 100644 index 00000000..5ba3db6c --- /dev/null +++ b/packages/charts/src/cartesian/composed.js @@ -0,0 +1,259 @@ +import { html, svg } from "@inglorious/web" + +import { renderTooltip } from "../component/tooltip.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 { createSharedContext } from "../utils/shared-context.js" + +const CARTESIAN_SERIES = new Set(["Line", "Area", "Bar"]) +const DEFAULT_SERIES_INDEX = 0 +const DEFAULT_SERIES_VALUE = 0 +const DEFAULT_INDEX_STEP = 1 + +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 + .filter((child) => child && CARTESIAN_SERIES.has(child.type)) + .map((child) => child.type), + ) + + const hasBarSeries = seriesTypes.has("Bar") + const hasAreaSeries = seriesTypes.has("Area") + const hasLineSeries = seriesTypes.has("Line") + const hasBrush = childrenArray.some((child) => child?.type === "Brush") + + 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 composedData = mergeComposedData( + entityWithData.data, + childrenArray.filter( + (child) => + child && + CARTESIAN_SERIES.has(child.type) && + Array.isArray(child.config?.data), + ), + ) + const contextEntity = + composedData.length > DEFAULT_SERIES_VALUE + ? entityWithData.__inline + ? Object.assign(entityWithData, { data: composedData }) + : { ...entityWithData, data: composedData } + : entityWithData + + 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: contextEntity, + }, + api, + ) + context.api = api + context.tooltipEnabled = tooltipEnabled + context.tooltipMode = tooltipMode + + 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) => + processDeclarativeChild(child, contextEntity, inferredChartType, api), + ) + .filter(Boolean) + + const { orderedChildren } = sortChildrenByLayer(processedChildrenArray, { + seriesFlag: ["isArea", "isBar", "isLine"], + reverseSeries: false, + 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` +
+ + ${clipPathId + ? html` + + + + + + ` + : ""} + ${finalRendered} + + ${renderTooltip(contextEntity, {}, 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 +} + +function inferSeriesDataKey(data, preferredKey) { + if (!Array.isArray(data) || data.length === DEFAULT_SERIES_VALUE) + 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] +} diff --git a/packages/charts/src/core/empty-instance.js b/packages/charts/src/core/empty-instance.js index 4f7e17f8..64e835f3 100644 --- a/packages/charts/src/core/empty-instance.js +++ b/packages/charts/src/core/empty-instance.js @@ -10,6 +10,7 @@ export function getEmptyChartInstance() { return { Dots: renderEmptyTemplate, Legend: renderEmptyTemplate, + renderChart: renderEmptyTemplate, ...chartMethods, ...compatibilityMethods, ...declarativeMethods, diff --git a/packages/charts/src/core/render-dispatch.js b/packages/charts/src/core/render-dispatch.js index 9190298f..b07a23e6 100644 --- a/packages/charts/src/core/render-dispatch.js +++ b/packages/charts/src/core/render-dispatch.js @@ -1,5 +1,6 @@ import { svg } from "@inglorious/web" +import { renderComposedChart } from "../cartesian/composed.js" import * as handlers from "../handlers.js" import { ensureChartRuntimeIdWithKey } from "../utils/runtime-id.js" @@ -27,6 +28,33 @@ export function renderByChartType(typeKey) { } } +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 (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, @@ -105,6 +133,56 @@ function normalizeRenderByTypeArgs(typeKey, firstArg, secondArg, thirdArg) { 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" + return null +} + +function isCartesianType(type) { + return type === "line" || type === "area" || type === "bar" +} + function isApiLike(value) { return value && typeof value.getType === "function" } diff --git a/packages/charts/src/index.js b/packages/charts/src/index.js index 309f01c2..65ced70a 100644 --- a/packages/charts/src/index.js +++ b/packages/charts/src/index.js @@ -8,22 +8,21 @@ import { getEmptyChartInstance } from "./core/empty-instance.js" import { buildComponentRenderer, renderByChartType, + renderChart, } from "./core/render-dispatch.js" import * as handlers from "./handlers.js" +import { render } from "./template.js" + export { STREAM_DEFAULTS } from "./realtime/defaults.js" export { lineChart } from "./realtime/stream-types.js" export { withRealtime } from "./realtime/with-realtime.js" -import { render } from "./template.js" -export { streamSlide } from "./utils/stream-slide.js" - -// Export chart types for config style -export { coreCharts } from "./core/chart-core.js" export { areaChart, barChart, donutChart, pieChart, } from "./utils/chart-utils.js" +export { streamSlide } from "./utils/stream-slide.js" const declarativeChildren = createDeclarativeChildren() @@ -33,6 +32,7 @@ export const chart = { core: coreCharts, // Chart Delegators + renderChart: renderChart(), renderLineChart: renderByChartType("line"), renderAreaChart: renderByChartType("area"), renderBarChart: renderByChartType("bar"), diff --git a/packages/charts/src/utils/cartesian-renderer.js b/packages/charts/src/utils/cartesian-renderer.js index 675a0d1d..29994a2a 100644 --- a/packages/charts/src/utils/cartesian-renderer.js +++ b/packages/charts/src/utils/cartesian-renderer.js @@ -166,6 +166,7 @@ export function sortChildrenByLayer( processedChildren, { seriesFlag, reverseSeries = false, includeBrush = false }, ) { + const seriesFlags = Array.isArray(seriesFlag) ? seriesFlag : [seriesFlag] const buckets = { grid: [], axes: [], @@ -184,7 +185,7 @@ export function sortChildrenByLayer( } if (child.isGrid) buckets.grid.push(child) else if (child.isAxis) buckets.axes.push(child) - else if (child[seriesFlag]) buckets.series.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) 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 +} From f3dbf853d2bb10b519eebef0daa0c24394de9af0 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Thu, 12 Mar 2026 14:17:20 +0100 Subject: [PATCH 15/34] fix: make composed series render correctly (band x-scale) and align tooltips --- packages/charts/src/cartesian/area.js | 158 ++++++++++++++++-- packages/charts/src/cartesian/bar.js | 41 ++++- packages/charts/src/cartesian/line.js | 91 +++++++++- packages/charts/src/utils/tooltip-handlers.js | 12 +- 4 files changed, 272 insertions(+), 30 deletions(-) diff --git a/packages/charts/src/cartesian/area.js b/packages/charts/src/cartesian/area.js index f0c96633..99372114 100644 --- a/packages/charts/src/cartesian/area.js +++ b/packages/charts/src/cartesian/area.js @@ -22,6 +22,7 @@ import { generateStackedAreaPath, } from "../utils/paths.js" import { processDeclarativeChild } from "../utils/process-declarative-child.js" +import { ensureChartRuntimeId } from "../utils/runtime-id.js" import { createSharedContext } from "../utils/shared-context.js" import { createTooltipHandlers } from "../utils/tooltip-handlers.js" @@ -104,6 +105,13 @@ export const area = { } const childrenArray = Array.isArray(children) ? children : [children] + const hasLineChildren = childrenArray.some( + (child) => child && child.type === "Line", + ) + const clipPathId = hasLineChildren + ? `chart-clip-${ensureChartRuntimeId(entityWithData)}` + : null + if (clipPathId) context.clipPathId = clipPathId const processedChildrenArray = childrenArray .map((child) => @@ -114,7 +122,7 @@ export const area = { const { orderedChildren: sortedChildren } = sortChildrenByLayer( processedChildrenArray, { - seriesFlag: "isArea", + seriesFlag: ["isArea", "isLine"], reverseSeries: !isStacked, }, ) @@ -135,6 +143,24 @@ export const area = { height=${context.dimensions.height} viewBox="0 0 ${context.dimensions.width} ${context.dimensions.height}" > + ${clipPathId + ? html` + + + + + + ` + : ""} ${finalRendered} ${renderTooltip(entityWithData, {}, api)} @@ -200,11 +226,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) { + renderYAxis(entity, { config = {} } = {}, api) { const axisFn = (ctx) => { const { yScale, dimensions } = ctx - return renderYAxis(ctx.entity || entity, { yScale, ...dimensions }, api) + return renderYAxis( + ctx.entity || entity, + { yScale, ...dimensions, ...config }, + api, + ) } axisFn.isAxis = true return axisFn @@ -217,7 +246,6 @@ 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 @@ -227,13 +255,32 @@ export const area = { 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 = ctx.entity || entity + 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(dataEntity, i) : i, + })) + if (isStacked) { const stackKey = String(stackId) const sums = @@ -244,21 +291,41 @@ export const area = { seriesStack.map((p) => p[1]), ) ctx.stack.computedByKey.set(`${stackKey}:${dataKey}`, seriesStack) - areaPath = generateStackedAreaPath(data, xScale, yScale, seriesStack) + areaPath = generateStackedAreaPath( + chartData, + scaleForSeries, + yScale, + seriesStack, + ) linePath = generateLinePath( - data.map((d, i) => ({ ...d, y: seriesStack[i][1] })), - xScale, + chartData.map((d, i) => ({ ...d, y: seriesStack[i][1] })), + scaleForSeries, yScale, ) } else { - areaPath = generateAreaPath(data, xScale, yScale, 0) - linePath = generateLinePath(data, xScale, yScale) + areaPath = generateAreaPath(chartData, scaleForSeries, yScale, 0) + linePath = generateLinePath(chartData, 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 @@ -277,7 +344,18 @@ export const area = { const { xScale, yScale } = ctx const entityFromContext = ctx.entity || entity const { dataKey, fill = "#8884d8" } = config - const data = getTransformedData(entityFromContext, dataKey) + const resolvedDataKey = + dataKey ?? + (Array.isArray(config.data) + ? inferSeriesDataKey(config.data, "area") + : undefined) + const dataEntity = Array.isArray(config.data) + ? { ...entityFromContext, data: config.data } + : entityFromContext + 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( @@ -288,12 +366,14 @@ export const area = { ${repeat( data, - (d, i) => `${dataKey}-${i}`, + (d, i) => `${resolvedDataKey || "value"}-${i}`, (d, i) => { - const x = xScale(d.x) + const x = scaleForSeries( + xScale.bandwidth ? resolveCategoryLabel(dataEntity, i) : d.x, + ) 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 = dataEntity.data[i] const label = originalDataPoint?.name || originalDataPoint?.label || @@ -304,6 +384,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, @@ -362,3 +447,46 @@ export const area = { */ renderBrush: createBrushComponent(), } + +function inferSeriesDataKey(data, preferredKey) { + if (!Array.isArray(data) || data.length === 0) return undefined + const sample = data[0] + 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[0] +} + +function resolveCategoryLabel(entity, index) { + const item = entity?.data?.[index] + return ( + item?.label ?? + item?.name ?? + item?.category ?? + item?.x ?? + item?.date ?? + String(index) + ) +} + +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 +} diff --git a/packages/charts/src/cartesian/bar.js b/packages/charts/src/cartesian/bar.js index d42e6029..6d041298 100644 --- a/packages/charts/src/cartesian/bar.js +++ b/packages/charts/src/cartesian/bar.js @@ -260,10 +260,18 @@ 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 if (!entityFromContext) return svg`` + const dataSource = Array.isArray(config.data) + ? config.data + : entityFromContext.data const entityColors = entityFromContext.colors || [ "#8884d8", "#82ca9d", @@ -290,9 +298,9 @@ export const bar = { } return svg` - ${entityFromContext.data.map((d, i) => { + ${dataSource.map((d, i) => { const category = d.label || d.name || d.category || String(i) - const value = d[dataKey] ?? 0 + const value = d[resolvedDataKey] ?? 0 const bandStart = xScale(category) // Skip if bandStart is undefined or NaN (invalid category) @@ -320,6 +328,11 @@ export const bar = { entity: entityFromContext, api, tooltipData: { label: category, value, color }, + enabled: + config.showTooltip ?? + (ctx.tooltipMode + ? ctx.tooltipMode === "all" + : ctx.tooltipEnabled), }) return renderRectangle({ x, @@ -335,7 +348,7 @@ export const bar = { } drawFn.isBar = true - drawFn.dataKey = config.dataKey || "value" + drawFn.dataKey = resolvedDataKey || "value" return drawFn }, @@ -447,3 +460,23 @@ export const bar = { */ renderBrush: createBrushComponent(), } + +function inferSeriesDataKey(data, preferredKey) { + if (!Array.isArray(data) || data.length === 0) return undefined + const sample = data[0] + 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[0] +} diff --git a/packages/charts/src/cartesian/line.js b/packages/charts/src/cartesian/line.js index 6e5a70ce..52651e39 100644 --- a/packages/charts/src/cartesian/line.js +++ b/packages/charts/src/cartesian/line.js @@ -130,7 +130,7 @@ export const line = { const { orderedChildren, buckets } = sortChildrenByLayer( processedChildrenArray, { - seriesFlag: "isLine", + seriesFlag: ["isLine", "isArea"], includeBrush: true, }, ) @@ -291,10 +291,24 @@ export const line = { 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 data = getTransformedData(dataEntity, resolvedDataKey) + const scaleForSeries = xScale.bandwidth + ? createBandCenterScale(xScale) + : xScale + const chartData = data.map((d, i) => ({ + ...d, + x: xScale.bandwidth ? resolveCategoryLabel(dataEntity, i) : i, + })) - const path = generateLinePath(chartData, xScale, yScale, type) + const path = generateLinePath(chartData, scaleForSeries, yScale, type) if (!path || path.includes("NaN")) return svg`` return svg` @@ -317,16 +331,27 @@ export const line = { const dotsFn = (ctx) => { const { xScale, yScale, entity: e } = ctx const { dataKey, fill = "#8884d8", r = "0.25em" } = config - const data = getTransformedData(e, dataKey) + const resolvedDataKey = + dataKey ?? + (Array.isArray(config.data) + ? inferSeriesDataKey(config.data, "line") + : undefined) + const dataEntity = Array.isArray(config.data) + ? { ...e, data: config.data } + : e + 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 originalDataPoint = dataEntity.data?.[i] const label = originalDataPoint?.name ?? originalDataPoint?.label ?? @@ -338,9 +363,16 @@ 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), }) return renderDot({ - cx: xScale(i), + cx: scaleForSeries( + xScale.bandwidth ? resolveCategoryLabel(dataEntity, i) : i, + ), cy: yScale(d.y), r, fill, @@ -398,3 +430,46 @@ function resolveClipPathId(ctx, entity) { ctx.clipPathId = clipPathId return clipPathId } + +function inferSeriesDataKey(data, preferredKey) { + if (!Array.isArray(data) || data.length === 0) return undefined + const sample = data[0] + 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[0] +} + +function resolveCategoryLabel(entity, index) { + const item = entity?.data?.[index] + return ( + item?.label ?? + item?.name ?? + item?.category ?? + item?.x ?? + item?.date ?? + String(index) + ) +} + +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 +} diff --git a/packages/charts/src/utils/tooltip-handlers.js b/packages/charts/src/utils/tooltip-handlers.js index e3396e06..afbc13ba 100644 --- a/packages/charts/src/utils/tooltip-handlers.js +++ b/packages/charts/src/utils/tooltip-handlers.js @@ -23,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 = @@ -64,7 +70,7 @@ 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") From d960d1e7955eea7adcaf25e4a8a961c58b35fdd7 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Thu, 12 Mar 2026 14:17:52 +0100 Subject: [PATCH 16/34] refactor: update composition demos to match composed chart --- examples/apps/web-charts/src/sections/area.js | 47 ++++++++++++------- examples/apps/web-charts/src/sections/bar.js | 2 +- .../apps/web-charts/src/sections/donut.js | 6 +-- examples/apps/web-charts/src/sections/line.js | 10 ++-- examples/apps/web-charts/src/sections/pie.js | 6 +-- 5 files changed, 43 insertions(+), 28 deletions(-) diff --git a/examples/apps/web-charts/src/sections/area.js b/examples/apps/web-charts/src/sections/area.js index 05486bd9..256841da 100644 --- a/examples/apps/web-charts/src/sections/area.js +++ b/examples/apps/web-charts/src/sections/area.js @@ -1,6 +1,16 @@ import { html } from "@inglorious/web" import { chart } from "@inglorious/charts" +const composedData = [ + { 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 }, +] + export function renderAreaSections(api) { return html`
@@ -11,7 +21,7 @@ export function renderAreaSections(api) {

Area Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderAreaChart( + ${chart.renderChart( api.getEntity("salesAreaChartComposition"), { width: 800, @@ -41,29 +51,34 @@ export function renderAreaSections(api) {
-

Area Chart - Composition (Padding 0)

- ${chart.renderAreaChart( - api.getEntity("salesAreaChartCompositionPadding"), +

Composed Area + Line + Bar (Composition)

+ ${chart.renderChart( { width: 800, height: 400, - padding: { top: 0, right: 0, bottom: 0, left: 0 }, - dataKeys: ["value"], + data: composedData, children: [ - chart.CartesianGrid({ - stroke: "#eee", - strokeDasharray: "5 5", - }), + chart.CartesianGrid(), chart.XAxis({ dataKey: "name" }), chart.YAxis({ width: "auto" }), chart.Area({ - dataKey: "value", + dataKey: "revenue", fill: "#8884d8", - fillOpacity: "0.6", + fillOpacity: "0.3", stroke: "#8884d8", + showDots: true, + showTooltip: true, + }), + chart.Bar({ + dataKey: "target", + fill: "#82ca9d", + showTooltip: true, + }), + chart.Line({ + dataKey: "forecast", + stroke: "#ff7300", + showDots: true, }), - chart.Dots({ dataKey: "value", fill: "#8884d8" }), - chart.Tooltip({}), ], }, api, @@ -82,7 +97,7 @@ export function renderAreaSections(api) { Area Chart Multi Series - Recharts Style (Composition with api.getEntity) - ${chart.renderAreaChart( + ${chart.renderChart( api.getEntity("multiSeriesAreaChartComposition"), { width: 800, @@ -147,7 +162,7 @@ export function renderAreaSections(api) {

Area Chart Stacked - Recharts Style (Composition with api.getEntity)

- ${chart.renderAreaChart( + ${chart.renderChart( api.getEntity("multiSeriesAreaChartStackedComposition"), { width: 800, diff --git a/examples/apps/web-charts/src/sections/bar.js b/examples/apps/web-charts/src/sections/bar.js index e0f2c465..159dc0dd 100644 --- a/examples/apps/web-charts/src/sections/bar.js +++ b/examples/apps/web-charts/src/sections/bar.js @@ -11,7 +11,7 @@ export function renderBarSection(api) {

Bar Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderBarChart( + ${chart.renderChart( api.getEntity("salesBarChartComposition"), { width: 800, diff --git a/examples/apps/web-charts/src/sections/donut.js b/examples/apps/web-charts/src/sections/donut.js index 09833f66..c344d3a5 100644 --- a/examples/apps/web-charts/src/sections/donut.js +++ b/examples/apps/web-charts/src/sections/donut.js @@ -22,7 +22,7 @@ export function renderDonutSection(api) {

Donut Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderPieChart( + ${chart.renderChart( api.getEntity("categoryDonutChartComposition"), { width: 500, @@ -48,7 +48,7 @@ export function renderDonutSection(api) {

Donut Chart - Composition (No id #1)

- ${chart.renderPieChart( + ${chart.renderChart( { type: "donut", data: inlineDonutDataA, @@ -78,7 +78,7 @@ export function renderDonutSection(api) {

Donut Chart - Composition (No id #2)

- ${chart.renderPieChart( + ${chart.renderChart( { type: "donut", data: inlineDonutDataB, diff --git a/examples/apps/web-charts/src/sections/line.js b/examples/apps/web-charts/src/sections/line.js index 0c957a7d..377dfa41 100644 --- a/examples/apps/web-charts/src/sections/line.js +++ b/examples/apps/web-charts/src/sections/line.js @@ -20,7 +20,7 @@ export function renderLineSections(api, status) {

Line Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderLineChart( + ${chart.renderChart( api.getEntity("salesLineChartComposition"), { width: 800, @@ -46,7 +46,7 @@ export function renderLineSections(api, status) {

Line Chart - Composition (No entity)

- ${chart.renderLineChart( + ${chart.renderChart( { data: inlineLineData, width: 600, @@ -80,7 +80,7 @@ export function renderLineSections(api, status) { Line Chart with Brush - Recharts Style (Composition with api.getEntity) - ${chart.renderLineChart( + ${chart.renderChart( api.getEntity("lineChartWithBrush"), { width: 800, @@ -115,7 +115,7 @@ export function renderLineSections(api, status) { Line Chart Multi Series - Recharts Style (Composition with api.getEntity) - ${chart.renderLineChart( + ${chart.renderChart( api.getEntity("multiSeriesLineChartComposition"), { width: 800, @@ -222,7 +222,7 @@ export function renderLineSections(api, status) { Pause
- ${chart.renderLineChart( + ${chart.renderChart( api.getEntity("realtimeLineChart"), { width: 800, diff --git a/examples/apps/web-charts/src/sections/pie.js b/examples/apps/web-charts/src/sections/pie.js index d1834dd7..98de178e 100644 --- a/examples/apps/web-charts/src/sections/pie.js +++ b/examples/apps/web-charts/src/sections/pie.js @@ -22,7 +22,7 @@ export function renderPieSection(api) {

Pie Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderPieChart( + ${chart.renderChart( api.getEntity("categoryPieChartComposition"), { width: 500, @@ -46,7 +46,7 @@ export function renderPieSection(api) {

Pie Chart - Composition (No id #1)

- ${chart.renderPieChart( + ${chart.renderChart( { type: "pie", data: inlinePieDataA, @@ -74,7 +74,7 @@ export function renderPieSection(api) {

Pie Chart - Composition (No id #2)

- ${chart.renderPieChart( + ${chart.renderChart( { type: "pie", data: inlinePieDataB, From be9de0e0a6933b0fd44a9a481cb8794a803a3d9f Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Thu, 12 Mar 2026 17:14:22 +0100 Subject: [PATCH 17/34] refactor: move composition orchestration to composed renderer and dumb-down series renderers --- packages/charts/src/cartesian/area.js | 215 +++-------------- packages/charts/src/cartesian/composed.js | 101 +++++--- packages/charts/src/cartesian/line.js | 216 ++++-------------- .../charts/src/utils/cartesian-helpers.js | 47 ++++ 4 files changed, 190 insertions(+), 389 deletions(-) create mode 100644 packages/charts/src/utils/cartesian-helpers.js diff --git a/packages/charts/src/cartesian/area.js b/packages/charts/src/cartesian/area.js index 99372114..8fe2cc9c 100644 --- a/packages/charts/src/cartesian/area.js +++ b/packages/charts/src/cartesian/area.js @@ -1,30 +1,29 @@ /* 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 { - createCartesianRenderer, - sortChildrenByLayer, -} from "../utils/cartesian-renderer.js" + createBandCenterScale, + inferSeriesDataKey, + resolveCategoryLabel, +} from "../utils/cartesian-helpers.js" +import { createCartesianRenderer } from "../utils/cartesian-renderer.js" import { getTransformedData } from "../utils/data-utils.js" -import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js" import { generateAreaPath, generateLinePath, generateStackedAreaPath, } from "../utils/paths.js" -import { processDeclarativeChild } from "../utils/process-declarative-child.js" -import { ensureChartRuntimeId } from "../utils/runtime-id.js" -import { createSharedContext } from "../utils/shared-context.js" import { createTooltipHandlers } from "../utils/tooltip-handlers.js" +import { renderComposedChart } from "./composed.js" export const area = { render: createCartesianRenderer({ @@ -46,126 +45,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 hasLineChildren = childrenArray.some( - (child) => child && child.type === "Line", - ) - const clipPathId = hasLineChildren - ? `chart-clip-${ensureChartRuntimeId(entityWithData)}` - : null - if (clipPathId) context.clipPathId = clipPathId - - const processedChildrenArray = childrenArray - .map((child) => - processDeclarativeChild(child, entityWithData, "area", api), - ) - .filter(Boolean) - - const { orderedChildren: sortedChildren } = sortChildrenByLayer( - processedChildrenArray, - { - seriesFlag: ["isArea", "isLine"], - reverseSeries: !isStacked, - }, - ) - - const finalRendered = sortedChildren.map((child) => { - if (typeof child !== "function") return child - const result = child(context) - return typeof result === "function" ? result(context) : result - }) - - return html` -
- - ${clipPathId - ? html` - - - - - - ` - : ""} - ${finalRendered} - - ${renderTooltip(entityWithData, {}, api)} -
- ` + renderAreaChart(entity, { children, config = {} }, api) { + return renderComposedChart(entity, { children, config }, api) }, /** @@ -264,6 +145,9 @@ export const area = { ? inferSeriesDataKey(config.data, "area") : undefined) const baseEntity = ctx.entity || 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 @@ -278,9 +162,16 @@ export const area = { : xScale const chartData = data.map((d, i) => ({ ...d, - x: xScale.bandwidth ? resolveCategoryLabel(dataEntity, i) : i, + 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 = @@ -292,19 +183,19 @@ export const area = { ) ctx.stack.computedByKey.set(`${stackKey}:${dataKey}`, seriesStack) areaPath = generateStackedAreaPath( - chartData, + clippedChartData, scaleForSeries, yScale, seriesStack, ) linePath = generateLinePath( - chartData.map((d, i) => ({ ...d, y: seriesStack[i][1] })), + clippedChartData.map((d, i) => ({ ...d, y: seriesStack[i][1] })), scaleForSeries, yScale, ) } else { - areaPath = generateAreaPath(chartData, scaleForSeries, yScale, 0) - linePath = generateLinePath(chartData, scaleForSeries, yScale) + areaPath = generateAreaPath(clippedChartData, scaleForSeries, yScale, 0) + linePath = generateLinePath(clippedChartData, scaleForSeries, yScale) } return svg` @@ -352,6 +243,9 @@ export const area = { 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) @@ -368,12 +262,18 @@ export const area = { data, (d, i) => `${resolvedDataKey || "value"}-${i}`, (d, i) => { + const resolvedIndex = i + indexOffset + if (typeof indexEnd === "number" && resolvedIndex > indexEnd) { + return svg`` + } const x = scaleForSeries( - xScale.bandwidth ? resolveCategoryLabel(dataEntity, i) : d.x, + 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 = dataEntity.data[i] + const originalDataPoint = plotEntity.data[resolvedIndex] const label = originalDataPoint?.name || originalDataPoint?.label || @@ -447,46 +347,3 @@ export const area = { */ renderBrush: createBrushComponent(), } - -function inferSeriesDataKey(data, preferredKey) { - if (!Array.isArray(data) || data.length === 0) return undefined - const sample = data[0] - 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[0] -} - -function resolveCategoryLabel(entity, index) { - const item = entity?.data?.[index] - return ( - item?.label ?? - item?.name ?? - item?.category ?? - item?.x ?? - item?.date ?? - String(index) - ) -} - -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 -} diff --git a/packages/charts/src/cartesian/composed.js b/packages/charts/src/cartesian/composed.js index 5ba3db6c..57ae63dd 100644 --- a/packages/charts/src/cartesian/composed.js +++ b/packages/charts/src/cartesian/composed.js @@ -1,15 +1,21 @@ +/* eslint-disable no-magic-numbers */ import { html, svg } from "@inglorious/web" import { renderTooltip } from "../component/tooltip.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 DEFAULT_SERIES_INDEX = 0 -const DEFAULT_SERIES_VALUE = 0 +const DEFAULT_SERIES_VALUE = DEFAULT_SERIES_INDEX const DEFAULT_INDEX_STEP = 1 export function renderComposedChart(entity, { children, config = {} }, api) { @@ -62,15 +68,17 @@ export function renderComposedChart(entity, { children, config = {} }, api) { autoDataKeys.forEach((key) => dataKeysSet.add(key)) } - const composedData = mergeComposedData( - entityWithData.data, - childrenArray.filter( - (child) => - child && - CARTESIAN_SERIES.has(child.type) && - Array.isArray(child.config?.data), - ), + 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 @@ -78,6 +86,16 @@ export function renderComposedChart(entity, { children, config = {} }, api) { : { ...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", ) @@ -105,13 +123,37 @@ export function renderComposedChart(entity, { children, config = {} }, api) { chartType: inferredChartType, stacked: isStacked, usedDataKeys: dataKeysSet, - filteredEntity: contextEntity, + 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 = { @@ -126,9 +168,16 @@ export function renderComposedChart(entity, { children, config = {} }, api) { if (clipPathId) context.clipPathId = clipPathId const processedChildrenArray = childrenArray - .map((child) => - processDeclarativeChild(child, contextEntity, inferredChartType, api), - ) + .map((child) => { + const targetEntity = + child && child.type === "Brush" ? context.fullEntity : contextEntity + return processDeclarativeChild( + child, + targetEntity, + inferredChartType, + api, + ) + }) .filter(Boolean) const { orderedChildren } = sortChildrenByLayer(processedChildrenArray, { @@ -160,6 +209,9 @@ export function renderComposedChart(entity, { children, config = {} }, api) { width=${context.dimensions.width} height=${context.dimensions.height} viewBox="0 0 ${context.dimensions.width} ${context.dimensions.height}" + @mousemove=${tooltipEnabled + ? createTooltipMoveHandler({ entity: contextEntity, api }) + : null} > ${clipPathId ? html` @@ -236,24 +288,3 @@ function mergeComposedData(baseData, seriesChildren) { return merged } - -function inferSeriesDataKey(data, preferredKey) { - if (!Array.isArray(data) || data.length === DEFAULT_SERIES_VALUE) - 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] -} diff --git a/packages/charts/src/cartesian/line.js b/packages/charts/src/cartesian/line.js index 52651e39..ab85ea53 100644 --- a/packages/charts/src/cartesian/line.js +++ b/packages/charts/src/cartesian/line.js @@ -1,29 +1,26 @@ /* 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 { - createCartesianRenderer, - sortChildrenByLayer, -} from "../utils/cartesian-renderer.js" -import { getTransformedData, parseDimension } from "../utils/data-utils.js" -import { extractDataKeysFromChildren } from "../utils/extract-data-keys.js" + createBandCenterScale, + inferSeriesDataKey, + resolveCategoryLabel, +} from "../utils/cartesian-helpers.js" +import { createCartesianRenderer } from "../utils/cartesian-renderer.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 = { render: createCartesianRenderer({ @@ -53,121 +50,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 isInlineEntity = entity?.__inline === true - const entityWithData = config.data - ? isInlineEntity - ? Object.assign(entity, { data: config.data }) - : { ...entity, data: config.data } - : isInlineEntity - ? entity - : { ...entity } - const clipPathId = ensureClipPathId(entityForBrush) - - // 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 - context.clipPathId = clipPathId - - // 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 { orderedChildren, buckets } = sortChildrenByLayer( - processedChildrenArray, - { - seriesFlag: ["isLine", "isArea"], - includeBrush: true, - }, - ) - - const processedChildren = orderedChildren.map((child) => - typeof child === "function" ? child(context) : child, - ) - - return html` -
- - - - - - - ${processedChildren} - - ${renderTooltip(entityWithData, {}, api)} -
- ` + renderLineChart(entity, { children, config = {} }, api) { + return renderComposedChart(entity, { children, config }, api) }, /** @@ -299,16 +183,31 @@ export const line = { const dataEntity = Array.isArray(config.data) ? { ...e, data: config.data } : e + const plotEntity = e?.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(dataEntity, i) : i, + x: xScale.bandwidth + ? resolveCategoryLabel(plotEntity, i + indexOffset) + : i + indexOffset, })) - const path = generateLinePath(chartData, scaleForSeries, yScale, type) + const clippedChartData = + typeof indexEnd === "number" + ? chartData.filter((point) => point.x <= indexEnd) + : chartData + + const path = generateLinePath( + clippedChartData, + scaleForSeries, + yScale, + type, + ) if (!path || path.includes("NaN")) return svg`` return svg` @@ -339,6 +238,9 @@ export const line = { const dataEntity = Array.isArray(config.data) ? { ...e, data: config.data } : e + const plotEntity = e?.fullEntity || dataEntity + const indexOffset = ctx.indexOffset ?? 0 + const indexEnd = ctx.indexEnd const data = getTransformedData(dataEntity, resolvedDataKey) const scaleForSeries = xScale.bandwidth ? createBandCenterScale(xScale) @@ -351,7 +253,8 @@ export const line = { data, (d, i) => `${resolvedDataKey || "value"}-${i}`, (d, i) => { - const originalDataPoint = dataEntity.data?.[i] + const resolvedIndex = i + indexOffset + const originalDataPoint = plotEntity.data?.[resolvedIndex] const label = originalDataPoint?.name ?? originalDataPoint?.label ?? @@ -369,9 +272,15 @@ export const line = { ? ctx.tooltipMode === "all" : ctx.tooltipEnabled), }) + if (typeof indexEnd === "number" && resolvedIndex > indexEnd) { + return svg`` + } + return renderDot({ cx: scaleForSeries( - xScale.bandwidth ? resolveCategoryLabel(dataEntity, i) : i, + xScale.bandwidth + ? resolveCategoryLabel(plotEntity, resolvedIndex) + : resolvedIndex, ), cy: yScale(d.y), r, @@ -430,46 +339,3 @@ function resolveClipPathId(ctx, entity) { ctx.clipPathId = clipPathId return clipPathId } - -function inferSeriesDataKey(data, preferredKey) { - if (!Array.isArray(data) || data.length === 0) return undefined - const sample = data[0] - 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[0] -} - -function resolveCategoryLabel(entity, index) { - const item = entity?.data?.[index] - return ( - item?.label ?? - item?.name ?? - item?.category ?? - item?.x ?? - item?.date ?? - String(index) - ) -} - -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 -} diff --git a/packages/charts/src/utils/cartesian-helpers.js b/packages/charts/src/utils/cartesian-helpers.js new file mode 100644 index 00000000..76c495ca --- /dev/null +++ b/packages/charts/src/utils/cartesian-helpers.js @@ -0,0 +1,47 @@ +/* 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 +} From e0b0687a8e2c86b96a9f28f7819cc5db3fa3a19c Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Thu, 12 Mar 2026 17:39:08 +0100 Subject: [PATCH 18/34] refactor: simplify bar renderer and delegate orchestration to composed --- packages/charts/src/cartesian/bar.js | 252 +++------------------------ 1 file changed, 20 insertions(+), 232 deletions(-) diff --git a/packages/charts/src/cartesian/bar.js b/packages/charts/src/cartesian/bar.js index 6d041298..7a4f8511 100644 --- a/packages/charts/src/cartesian/bar.js +++ b/packages/charts/src/cartesian/bar.js @@ -1,19 +1,17 @@ /* 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-geometry.js" -import { calculatePadding } from "../utils/padding.js" -import { processDeclarativeChild } from "../utils/process-declarative-child.js" -import { createCartesianContext } from "../utils/scales.js" +import { inferSeriesDataKey } from "../utils/cartesian-helpers.js" import { createTooltipHandlers } from "../utils/tooltip-handlers.js" +import { renderComposedChart } from "./composed.js" export const bar = { /** @@ -28,34 +26,22 @@ export const bar = { const type = api.getType(entity.type) const children = [ entity.showGrid !== false - ? type.renderCartesianGrid(entity, {}, api) + ? chart.CartesianGrid({ stroke: "#eee", strokeDasharray: "5 5" }) : null, - type.renderXAxis(entity, {}, api), - type.renderYAxis(entity, {}, api), - type.renderBar( - entity, - { config: { dataKey: "value", multiColor: false } }, - api, - ), + chart.XAxis({}), + chart.YAxis({ width: "auto" }), + chart.Bar({ dataKey: "value", multiColor: false }), + entity.showTooltip !== false ? chart.Tooltip({}) : null, ].filter(Boolean) - 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` - - ${processedChildren} - - ` - - if (config.isRawSVG) return svgContent - - return html` -
- ${svgContent} ${renderTooltip(entity, {}, api)} -
- ` + renderBarChart(entity, { children, config = {} }, api) { + return renderComposedChart(entity, { children, config }, api) }, /** @@ -299,7 +106,8 @@ export const bar = { return svg` ${dataSource.map((d, i) => { - const category = d.label || d.name || d.category || String(i) + const category = d.label || d.name || d.category + const label = category ?? String(i) const value = d[resolvedDataKey] ?? 0 const bandStart = xScale(category) @@ -327,7 +135,7 @@ export const bar = { const { onMouseEnter, onMouseLeave } = createTooltipHandlers({ entity: entityFromContext, api, - tooltipData: { label: category, value, color }, + tooltipData: { label, value, color }, enabled: config.showTooltip ?? (ctx.tooltipMode @@ -460,23 +268,3 @@ export const bar = { */ renderBrush: createBrushComponent(), } - -function inferSeriesDataKey(data, preferredKey) { - if (!Array.isArray(data) || data.length === 0) return undefined - const sample = data[0] - 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[0] -} From 78c10f186ab4b999535ded36f4bb4c1c51ec8097 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Thu, 12 Mar 2026 18:03:02 +0100 Subject: [PATCH 19/34] fix: detect function series in composed renderer and update bar test --- packages/charts/src/cartesian/bar.test.js | 8 ++------ packages/charts/src/cartesian/composed.js | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/charts/src/cartesian/bar.test.js b/packages/charts/src/cartesian/bar.test.js index 648f1d49..a48a724a 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", () => { diff --git a/packages/charts/src/cartesian/composed.js b/packages/charts/src/cartesian/composed.js index 57ae63dd..803663e0 100644 --- a/packages/charts/src/cartesian/composed.js +++ b/packages/charts/src/cartesian/composed.js @@ -30,14 +30,25 @@ export function renderComposedChart(entity, { children, config = {} }, api) { const seriesTypes = new Set( childrenArray - .filter((child) => child && CARTESIAN_SERIES.has(child.type)) - .map((child) => child.type), + .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") + const hasBrush = childrenArray.some( + (child) => child?.type === "Brush" || child?.isBrush, + ) const inferredChartType = hasBarSeries ? "bar" From 7f1aae18ca0cadc5eb77ddc5ce3eada56390a063 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 13 Mar 2026 17:20:56 +0100 Subject: [PATCH 20/34] refactor: add composed chart support for config style charts --- examples/apps/web-charts/src/store/index.js | 2 + packages/charts/src/cartesian/composed.js | 80 +++++++++++++++++++++ packages/charts/src/core/chart-core.js | 2 + packages/charts/src/core/render-dispatch.js | 27 ++++++- packages/charts/src/index.js | 1 + packages/charts/src/utils/chart-utils.js | 2 + 6 files changed, 113 insertions(+), 1 deletion(-) 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/src/cartesian/composed.js b/packages/charts/src/cartesian/composed.js index 803663e0..656e9e8f 100644 --- a/packages/charts/src/cartesian/composed.js +++ b/packages/charts/src/cartesian/composed.js @@ -15,9 +15,18 @@ 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` @@ -249,6 +258,68 @@ export function renderComposedChart(entity, { children, config = {} }, api) { ` } +export function buildComposedChildren(entity) { + const children = [] + if (!entity) return children + + if (entity.showGrid !== false) { + children.push({ + type: "CartesianGrid", + config: { stroke: "#eee", strokeDasharray: "5 5" }, + }) + } + + children.push({ + type: "XAxis", + config: { dataKey: resolveXAxisDataKey(entity) }, + }) + children.push({ type: "YAxis", config: { width: "auto" } }) + + 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 no-unused-vars */ + const { kind: _kind, type: _type, ...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 })) @@ -299,3 +370,12 @@ function mergeComposedData(baseData, seriesChildren) { return merged } + +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" +} diff --git a/packages/charts/src/core/chart-core.js b/packages/charts/src/core/chart-core.js index 1e13ccc9..df59a6bd 100644 --- a/packages/charts/src/core/chart-core.js +++ b/packages/charts/src/core/chart-core.js @@ -1,5 +1,6 @@ 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" @@ -16,6 +17,7 @@ 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), } diff --git a/packages/charts/src/core/render-dispatch.js b/packages/charts/src/core/render-dispatch.js index b07a23e6..25ba0c04 100644 --- a/packages/charts/src/core/render-dispatch.js +++ b/packages/charts/src/core/render-dispatch.js @@ -1,6 +1,9 @@ import { svg } from "@inglorious/web" -import { renderComposedChart } from "../cartesian/composed.js" +import { + buildComposedChildren, + renderComposedChart, +} from "../cartesian/composed.js" import * as handlers from "../handlers.js" import { ensureChartRuntimeIdWithKey } from "../utils/runtime-id.js" @@ -41,6 +44,27 @@ export function renderChart() { 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, @@ -176,6 +200,7 @@ function normalizeChartType(type) { if (lowered === "area") return "area" if (lowered === "bar") return "bar" if (lowered === "pie") return "pie" + if (lowered === "composed") return "composed" return null } diff --git a/packages/charts/src/index.js b/packages/charts/src/index.js index 65ced70a..4a8392f0 100644 --- a/packages/charts/src/index.js +++ b/packages/charts/src/index.js @@ -19,6 +19,7 @@ export { withRealtime } from "./realtime/with-realtime.js" export { areaChart, barChart, + composedChart, donutChart, pieChart, } from "./utils/chart-utils.js" 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) From 4c553038f57210a4c7ca99b8c96402878fccc393 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Fri, 13 Mar 2026 18:20:56 +0100 Subject: [PATCH 21/34] refactor: extract shared cartesian children and renderer utilities --- packages/charts/src/cartesian/bar.js | 14 ++-- packages/charts/src/cartesian/composed.js | 30 ++------- .../charts/src/utils/cartesian-children.js | 66 +++++++++++++++++++ .../charts/src/utils/cartesian-renderer.js | 31 +++------ 4 files changed, 88 insertions(+), 53 deletions(-) create mode 100644 packages/charts/src/utils/cartesian-children.js diff --git a/packages/charts/src/cartesian/bar.js b/packages/charts/src/cartesian/bar.js index 7a4f8511..d35b2ce0 100644 --- a/packages/charts/src/cartesian/bar.js +++ b/packages/charts/src/cartesian/bar.js @@ -9,6 +9,7 @@ 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 { buildCartesianBaseChildren } from "../utils/cartesian-children.js" import { inferSeriesDataKey } from "../utils/cartesian-helpers.js" import { createTooltipHandlers } from "../utils/tooltip-handlers.js" import { renderComposedChart } from "./composed.js" @@ -24,15 +25,10 @@ export const bar = { */ render(entity, api) { const type = api.getType(entity.type) - const children = [ - entity.showGrid !== false - ? chart.CartesianGrid({ stroke: "#eee", strokeDasharray: "5 5" }) - : null, - chart.XAxis({}), - chart.YAxis({ width: "auto" }), - chart.Bar({ dataKey: "value", multiColor: false }), - entity.showTooltip !== false ? chart.Tooltip({}) : null, - ].filter(Boolean) + const children = buildCartesianBaseChildren(entity, { + makeChild: (typeKey, config) => chart[typeKey](config), + }) + children.push(chart.Bar({ dataKey: "value", multiColor: false })) return type.renderBarChart( entity, diff --git a/packages/charts/src/cartesian/composed.js b/packages/charts/src/cartesian/composed.js index 656e9e8f..598c55cf 100644 --- a/packages/charts/src/cartesian/composed.js +++ b/packages/charts/src/cartesian/composed.js @@ -2,6 +2,10 @@ 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, @@ -259,21 +263,10 @@ export function renderComposedChart(entity, { children, config = {} }, api) { } export function buildComposedChildren(entity) { - const children = [] - if (!entity) return children - - if (entity.showGrid !== false) { - children.push({ - type: "CartesianGrid", - config: { stroke: "#eee", strokeDasharray: "5 5" }, - }) - } - - children.push({ - type: "XAxis", - config: { dataKey: resolveXAxisDataKey(entity) }, + const children = buildCartesianBaseChildren(entity, { + includeTooltip: false, + includeBrush: false, }) - children.push({ type: "YAxis", config: { width: "auto" } }) const series = Array.isArray(entity.series) ? entity.series : [] series.forEach((item) => { @@ -370,12 +363,3 @@ function mergeComposedData(baseData, seriesChildren) { return merged } - -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" -} diff --git a/packages/charts/src/utils/cartesian-children.js b/packages/charts/src/utils/cartesian-children.js new file mode 100644 index 00000000..17a41ed1 --- /dev/null +++ b/packages/charts/src/utils/cartesian-children.js @@ -0,0 +1,66 @@ +/* eslint-disable no-magic-numbers */ +const DEFAULT_GRID_CONFIG = { stroke: "#eee", strokeDasharray: "5 5" } +const DEFAULT_Y_AXIS_CONFIG = { width: "auto" } + +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-renderer.js b/packages/charts/src/utils/cartesian-renderer.js index 29994a2a..b588fcf0 100644 --- a/packages/charts/src/utils/cartesian-renderer.js +++ b/packages/charts/src/utils/cartesian-renderer.js @@ -1,4 +1,8 @@ /* eslint-disable no-magic-numbers */ +import { + buildCartesianBaseChildren, + resolveXAxisDataKey, +} from "./cartesian-children.js" import { isMultiSeries } from "./data-utils.js" const DEFAULT_COLORS = ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"] @@ -54,17 +58,11 @@ export function buildCartesianChildrenFromConfig( entity, { chartApi, seriesType, providedDataKeys = null }, ) { - const children = [] - - if (entity.showGrid !== false) { - children.push( - chartApi.CartesianGrid({ stroke: "#eee", strokeDasharray: "5 5" }), - ) - } - - const xAxisDataKey = resolveXAxisDataKey(entity) - children.push(chartApi.XAxis({ dataKey: xAxisDataKey })) - children.push(chartApi.YAxis({ width: "auto" })) + const children = buildCartesianBaseChildren(entity, { + makeChild: (type, config) => chartApi[type](config), + includeTooltip: false, + includeBrush: false, + }) const dataKeys = providedDataKeys?.length ? providedDataKeys @@ -142,7 +140,7 @@ export function buildCartesianChildrenFromConfig( if (entity.brush?.enabled && entity.brush?.visible !== false) { children.push( chartApi.Brush({ - dataKey: xAxisDataKey, + dataKey: resolveXAxisDataKey(entity), height: entity.brush.height || 30, }), ) @@ -279,15 +277,6 @@ export function createCartesianRenderer({ } } -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" -} - function resolveDataKeys(data) { if (!Array.isArray(data) || data.length === 0) return ["value"] From 29ea1beb3283869ed947222525b41ac0da2d109f Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 09:44:46 +0100 Subject: [PATCH 22/34] refactor: introduce getResolvedEntity helper and sanitize composed config --- packages/charts/src/cartesian/area.js | 18 +++++++------- packages/charts/src/cartesian/bar.js | 24 +++++++++---------- packages/charts/src/cartesian/composed.js | 4 ++-- packages/charts/src/cartesian/line.js | 18 ++++++++------ .../charts/src/utils/cartesian-helpers.js | 16 +++++++++++++ .../charts/src/utils/cartesian-renderer.js | 5 ++-- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/charts/src/cartesian/area.js b/packages/charts/src/cartesian/area.js index 8fe2cc9c..eab39c8f 100644 --- a/packages/charts/src/cartesian/area.js +++ b/packages/charts/src/cartesian/area.js @@ -12,10 +12,12 @@ import { renderCurve } from "../shape/curve.js" import { renderDot } from "../shape/dot.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, @@ -60,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, ) @@ -79,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), ) @@ -107,11 +109,11 @@ export const area = { * @param {import('@inglorious/web').Api} api * @returns {(ctx: Record) => import('lit-html').TemplateResult} */ - renderYAxis(entity, { config = {} } = {}, api) { + renderYAxis(entity, { config = {} }, api) { const axisFn = (ctx) => { const { yScale, dimensions } = ctx return renderYAxis( - ctx.entity || entity, + getResolvedEntity(ctx, entity), { yScale, ...dimensions, ...config }, api, ) @@ -132,7 +134,7 @@ export const area = { const { xScale, yScale } = ctx const { dataKey, - fill = "#8884d8", + fill = PALETTE_DEFAULT[0], fillOpacity = "0.6", stroke, stackId, @@ -144,7 +146,7 @@ export const area = { (Array.isArray(config.data) ? inferSeriesDataKey(config.data, "area") : undefined) - const baseEntity = ctx.entity || entity + const baseEntity = getResolvedEntity(ctx, entity) const plotEntity = ctx.fullEntity || baseEntity const indexOffset = ctx.indexOffset ?? 0 const indexEnd = ctx.indexEnd @@ -233,8 +235,8 @@ 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 entityFromContext = getResolvedEntity(ctx, entity) + const { dataKey, fill = PALETTE_DEFAULT[0] } = config const resolvedDataKey = dataKey ?? (Array.isArray(config.data) diff --git a/packages/charts/src/cartesian/bar.js b/packages/charts/src/cartesian/bar.js index d35b2ce0..d7727b8b 100644 --- a/packages/charts/src/cartesian/bar.js +++ b/packages/charts/src/cartesian/bar.js @@ -10,7 +10,11 @@ import { renderYAxis } from "../component/y-axis.js" import { chart } from "../index.js" import { renderRectangle } from "../shape/rectangle.js" import { buildCartesianBaseChildren } from "../utils/cartesian-children.js" -import { inferSeriesDataKey } from "../utils/cartesian-helpers.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" @@ -70,17 +74,12 @@ export const bar = { ? 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 dataSource = Array.isArray(config.data) ? config.data : entityFromContext.data - const entityColors = entityFromContext.colors || [ - "#8884d8", - "#82ca9d", - "#ffc658", - "#ff7300", - ] + 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 @@ -167,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, @@ -233,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, @@ -241,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/composed.js b/packages/charts/src/cartesian/composed.js index 598c55cf..48c2f2d1 100644 --- a/packages/charts/src/cartesian/composed.js +++ b/packages/charts/src/cartesian/composed.js @@ -274,8 +274,8 @@ export function buildComposedChildren(entity) { const kind = (item.kind || item.type || "").toLowerCase() const type = KIND_TO_TYPE[kind] if (!type) return - /* eslint-disable no-unused-vars */ - const { kind: _kind, type: _type, ...config } = item + // eslint-disable-next-line no-unused-vars + const { kind: kindValue, type: typeValue, ...config } = item children.push({ type, config }) }) diff --git a/packages/charts/src/cartesian/line.js b/packages/charts/src/cartesian/line.js index ab85ea53..cb496b91 100644 --- a/packages/charts/src/cartesian/line.js +++ b/packages/charts/src/cartesian/line.js @@ -11,10 +11,12 @@ import { chart } from "../index.js" import { renderDot } from "../shape/dot.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 { ensureChartRuntimeId } from "../utils/runtime-id.js" @@ -168,10 +170,11 @@ 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 @@ -183,7 +186,7 @@ export const line = { const dataEntity = Array.isArray(config.data) ? { ...e, data: config.data } : e - const plotEntity = e?.fullEntity || dataEntity + const plotEntity = ctx.fullEntity || dataEntity const indexOffset = ctx.indexOffset ?? 0 const indexEnd = ctx.indexEnd const data = getTransformedData(dataEntity, resolvedDataKey) @@ -228,8 +231,9 @@ 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 { 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) @@ -238,7 +242,7 @@ export const line = { const dataEntity = Array.isArray(config.data) ? { ...e, data: config.data } : e - const plotEntity = e?.fullEntity || dataEntity + const plotEntity = ctx.fullEntity || dataEntity const indexOffset = ctx.indexOffset ?? 0 const indexEnd = ctx.indexEnd const data = getTransformedData(dataEntity, resolvedDataKey) @@ -308,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) } diff --git a/packages/charts/src/utils/cartesian-helpers.js b/packages/charts/src/utils/cartesian-helpers.js index 76c495ca..cab99e16 100644 --- a/packages/charts/src/utils/cartesian-helpers.js +++ b/packages/charts/src/utils/cartesian-helpers.js @@ -45,3 +45,19 @@ export function createBandCenterScale(bandScale) { 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-renderer.js b/packages/charts/src/utils/cartesian-renderer.js index b588fcf0..6ab9838a 100644 --- a/packages/charts/src/utils/cartesian-renderer.js +++ b/packages/charts/src/utils/cartesian-renderer.js @@ -3,9 +3,8 @@ import { buildCartesianBaseChildren, resolveXAxisDataKey, } from "./cartesian-children.js" +import { PALETTE_DEFAULT } from "./constants.js" import { isMultiSeries } from "./data-utils.js" - -const DEFAULT_COLORS = ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"] const EXCLUDED_AXIS_KEYS = new Set(["name", "x", "date"]) /** @@ -68,7 +67,7 @@ export function buildCartesianChildrenFromConfig( ? providedDataKeys : resolveDataKeys(entity.data) - const colors = entity.colors || DEFAULT_COLORS + const colors = entity.colors || PALETTE_DEFAULT const isStackedArea = seriesType === "area" && entity.stacked === true dataKeys.forEach((dataKey, index) => { From 01724a13ce84e9365afd4534317eaa7ff1b8243f Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 09:45:25 +0100 Subject: [PATCH 23/34] refactor: centralize shared constants and color palettes --- .../charts/src/utils/cartesian-children.js | 3 +- packages/charts/src/utils/colors.js | 4 +-- packages/charts/src/utils/constants.js | 28 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 packages/charts/src/utils/constants.js diff --git a/packages/charts/src/utils/cartesian-children.js b/packages/charts/src/utils/cartesian-children.js index 17a41ed1..8ece7960 100644 --- a/packages/charts/src/utils/cartesian-children.js +++ b/packages/charts/src/utils/cartesian-children.js @@ -1,6 +1,5 @@ /* eslint-disable no-magic-numbers */ -const DEFAULT_GRID_CONFIG = { stroke: "#eee", strokeDasharray: "5 5" } -const DEFAULT_Y_AXIS_CONFIG = { width: "auto" } +import { DEFAULT_GRID_CONFIG, DEFAULT_Y_AXIS_CONFIG } from "./constants.js" export function resolveXAxisDataKey(entity) { let dataKey = entity?.dataKey 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", +} From 966313717b9ecc152fd6912dbf0021ace4891d51 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 10:29:35 +0100 Subject: [PATCH 24/34] refactor: unify data key inference and complete context resolution --- packages/charts/src/component/brush.js | 3 +- packages/charts/src/component/tooltip.js | 3 +- .../charts/src/utils/cartesian-renderer.js | 22 +-------------- packages/charts/src/utils/data-utils.js | 28 +++++++++++++++++++ 4 files changed, 33 insertions(+), 23 deletions(-) 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/tooltip.js b/packages/charts/src/component/tooltip.js index 9078e3b3..c86d2016 100644 --- a/packages/charts/src/component/tooltip.js +++ b/packages/charts/src/component/tooltip.js @@ -1,5 +1,6 @@ import { html } from "@inglorious/web" +import { getResolvedEntity } from "../utils/cartesian-helpers.js" import { formatNumber } from "../utils/data-utils.js" /** @@ -55,7 +56,7 @@ export function renderTooltip(entity, props, api) { export function createTooltipComponent() { return (entity, props, api) => { 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/utils/cartesian-renderer.js b/packages/charts/src/utils/cartesian-renderer.js index 6ab9838a..2535b7ee 100644 --- a/packages/charts/src/utils/cartesian-renderer.js +++ b/packages/charts/src/utils/cartesian-renderer.js @@ -4,8 +4,7 @@ import { resolveXAxisDataKey, } from "./cartesian-children.js" import { PALETTE_DEFAULT } from "./constants.js" -import { isMultiSeries } from "./data-utils.js" -const EXCLUDED_AXIS_KEYS = new Set(["name", "x", "date"]) +import { isMultiSeries, resolveDataKeys } from "./data-utils.js" /** * Converts long multi-series input into wide rows, reusing the x value as row key. @@ -276,25 +275,6 @@ export function createCartesianRenderer({ } } -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"] -} - function getRenderMethod(seriesType) { return seriesType === "area" ? "renderAreaChart" : "renderLineChart" } 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. From 56b6905d3eb29b5aa34a1eb9ac7313e367beda02 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 11:23:41 +0100 Subject: [PATCH 25/34] refactor: standardize context resolution across all renderers --- packages/charts/src/cartesian/area.js | 2 +- packages/charts/src/cartesian/bar.js | 2 +- packages/charts/src/polar/pie.js | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/charts/src/cartesian/area.js b/packages/charts/src/cartesian/area.js index eab39c8f..d47d96b2 100644 --- a/packages/charts/src/cartesian/area.js +++ b/packages/charts/src/cartesian/area.js @@ -329,7 +329,7 @@ export const area = { })) return renderLegend( - ctx.entity || entity, + getResolvedEntity(ctx, entity), { series, colors: colors || [], diff --git a/packages/charts/src/cartesian/bar.js b/packages/charts/src/cartesian/bar.js index d7727b8b..fd7ccbe0 100644 --- a/packages/charts/src/cartesian/bar.js +++ b/packages/charts/src/cartesian/bar.js @@ -205,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, { 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`` } From 1df0fa33feb300d516fa61e1d91bb6bb59985a6e Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 11:26:50 +0100 Subject: [PATCH 26/34] refactor: rely on charts defaults for grid, axes, brush and tooltip --- examples/apps/web-charts/src/sections/area.js | 23 +++------ examples/apps/web-charts/src/sections/bar.js | 5 +- examples/apps/web-charts/src/sections/line.js | 51 +++++++------------ 3 files changed, 26 insertions(+), 53 deletions(-) diff --git a/examples/apps/web-charts/src/sections/area.js b/examples/apps/web-charts/src/sections/area.js index 256841da..b21a40ed 100644 --- a/examples/apps/web-charts/src/sections/area.js +++ b/examples/apps/web-charts/src/sections/area.js @@ -28,12 +28,9 @@ export function renderAreaSections(api) { 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.Area({ dataKey: "value", fill: "#8884d8", @@ -60,7 +57,7 @@ export function renderAreaSections(api) { children: [ chart.CartesianGrid(), chart.XAxis({ dataKey: "name" }), - chart.YAxis({ width: "auto" }), + chart.YAxis(), chart.Area({ dataKey: "revenue", fill: "#8884d8", @@ -104,12 +101,9 @@ export function renderAreaSections(api) { height: 400, dataKeys: ["Revenue", "Expenses", "Profit"], children: [ - chart.CartesianGrid({ - stroke: "#eee", - strokeDasharray: "5 5", - }), + chart.CartesianGrid(), chart.XAxis({ dataKey: "name" }), - chart.YAxis({ width: "auto" }), + chart.YAxis(), chart.Area({ dataKey: "Revenue", fill: "#8884d8", @@ -169,12 +163,9 @@ export function renderAreaSections(api) { height: 400, dataKeys: ["Revenue", "Expenses", "Profit"], children: [ - chart.CartesianGrid({ - stroke: "#eee", - strokeDasharray: "5 5", - }), + chart.CartesianGrid(), chart.XAxis({ dataKey: "name" }), - chart.YAxis({ width: "auto" }), + chart.YAxis(), chart.Area({ dataKey: "Revenue", fill: "#8884d8", diff --git a/examples/apps/web-charts/src/sections/bar.js b/examples/apps/web-charts/src/sections/bar.js index 159dc0dd..cb98f32a 100644 --- a/examples/apps/web-charts/src/sections/bar.js +++ b/examples/apps/web-charts/src/sections/bar.js @@ -18,13 +18,12 @@ export function renderBarSection(api) { height: 400, children: [ chart.CartesianGrid({ - stroke: "#eee", strokeDasharray: "3 3", }), chart.XAxis({ dataKey: "label" }), - chart.YAxis({ width: "auto" }), + chart.YAxis(), chart.Bar({ dataKey: "value" }), - chart.Tooltip({}), + chart.Tooltip(), ], }, api, diff --git a/examples/apps/web-charts/src/sections/line.js b/examples/apps/web-charts/src/sections/line.js index 377dfa41..0bb77640 100644 --- a/examples/apps/web-charts/src/sections/line.js +++ b/examples/apps/web-charts/src/sections/line.js @@ -27,15 +27,12 @@ export function renderLineSections(api, status) { 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, @@ -53,15 +50,12 @@ export function renderLineSections(api, status) { height: 240, 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({}), + chart.Tooltip(), ], }, api, @@ -87,16 +81,13 @@ export function renderLineSections(api, status) { 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, @@ -122,12 +113,9 @@ export function renderLineSections(api, status) { 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", @@ -164,7 +152,7 @@ export function renderLineSections(api, status) { dataKeys: ["productA", "productB", "productC", "productD"], colors: ["#8884d8", "#82ca9d", "#ffc658", "#ff8042"], }), - chart.Tooltip({}), + chart.Tooltip(), ], }, api, @@ -229,18 +217,13 @@ export function renderLineSections(api, status) { 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, From 00b4b25165e3eda930faf883130a1b5f71a96dc5 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 11:52:27 +0100 Subject: [PATCH 27/34] test: add coverage for data key inference and cartesian context helper for charts --- .../src/utils/cartesian-helpers.test.js | 35 +++++++++++++++ packages/charts/src/utils/data-utils.test.js | 43 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 packages/charts/src/utils/cartesian-helpers.test.js 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/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"]) + }) + }) }) From 6e3d8be6dfbde1b954aa19ba07ff2bce7b0053d3 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 15:58:32 +0100 Subject: [PATCH 28/34] refactor: adjust charts api to expose unified chart.render for composition mode --- packages/charts/src/index.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/charts/src/index.js b/packages/charts/src/index.js index 4a8392f0..ef8b171a 100644 --- a/packages/charts/src/index.js +++ b/packages/charts/src/index.js @@ -5,13 +5,8 @@ import { } from "./core/create-chart-instance.js" import { createDeclarativeChildren } from "./core/declarative-children.js" import { getEmptyChartInstance } from "./core/empty-instance.js" -import { - buildComponentRenderer, - renderByChartType, - renderChart, -} from "./core/render-dispatch.js" +import { buildComponentRenderer, renderChart } from "./core/render-dispatch.js" import * as handlers from "./handlers.js" -import { render } from "./template.js" export { STREAM_DEFAULTS } from "./realtime/defaults.js" export { lineChart } from "./realtime/stream-types.js" @@ -29,15 +24,11 @@ const declarativeChildren = createDeclarativeChildren() export const chart = { ...handlers, - render, core: coreCharts, // Chart Delegators - renderChart: renderChart(), - renderLineChart: renderByChartType("line"), - renderAreaChart: renderByChartType("area"), - renderBarChart: renderByChartType("bar"), - renderPieChart: renderByChartType("pie"), + // Unified Composition-mode renderer + render: renderChart(), // Component Renderers (Abstracted) renderLine: buildComponentRenderer("renderLine", "line"), From 208f5c2d54dd84cb6f7659610a68aa41008ffbef Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 15:59:14 +0100 Subject: [PATCH 29/34] refactor: update web-charts examples to use unified chart.render in composition mode --- examples/apps/web-charts/src/sections/area.js | 8 ++++---- examples/apps/web-charts/src/sections/bar.js | 2 +- examples/apps/web-charts/src/sections/donut.js | 6 +++--- examples/apps/web-charts/src/sections/line.js | 10 +++++----- examples/apps/web-charts/src/sections/pie.js | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/examples/apps/web-charts/src/sections/area.js b/examples/apps/web-charts/src/sections/area.js index b21a40ed..0add0a04 100644 --- a/examples/apps/web-charts/src/sections/area.js +++ b/examples/apps/web-charts/src/sections/area.js @@ -21,7 +21,7 @@ export function renderAreaSections(api) {

Area Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderChart( + ${chart.render( api.getEntity("salesAreaChartComposition"), { width: 800, @@ -49,7 +49,7 @@ export function renderAreaSections(api) {

Composed Area + Line + Bar (Composition)

- ${chart.renderChart( + ${chart.render( { width: 800, height: 400, @@ -94,7 +94,7 @@ export function renderAreaSections(api) { Area Chart Multi Series - Recharts Style (Composition with api.getEntity) - ${chart.renderChart( + ${chart.render( api.getEntity("multiSeriesAreaChartComposition"), { width: 800, @@ -156,7 +156,7 @@ export function renderAreaSections(api) {

Area Chart Stacked - Recharts Style (Composition with api.getEntity)

- ${chart.renderChart( + ${chart.render( api.getEntity("multiSeriesAreaChartStackedComposition"), { width: 800, diff --git a/examples/apps/web-charts/src/sections/bar.js b/examples/apps/web-charts/src/sections/bar.js index cb98f32a..08d8a078 100644 --- a/examples/apps/web-charts/src/sections/bar.js +++ b/examples/apps/web-charts/src/sections/bar.js @@ -11,7 +11,7 @@ export function renderBarSection(api) {

Bar Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderChart( + ${chart.render( api.getEntity("salesBarChartComposition"), { width: 800, diff --git a/examples/apps/web-charts/src/sections/donut.js b/examples/apps/web-charts/src/sections/donut.js index c344d3a5..a44d4659 100644 --- a/examples/apps/web-charts/src/sections/donut.js +++ b/examples/apps/web-charts/src/sections/donut.js @@ -22,7 +22,7 @@ export function renderDonutSection(api) {

Donut Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderChart( + ${chart.render( api.getEntity("categoryDonutChartComposition"), { width: 500, @@ -48,7 +48,7 @@ export function renderDonutSection(api) {

Donut Chart - Composition (No id #1)

- ${chart.renderChart( + ${chart.render( { type: "donut", data: inlineDonutDataA, @@ -78,7 +78,7 @@ export function renderDonutSection(api) {

Donut Chart - Composition (No id #2)

- ${chart.renderChart( + ${chart.render( { type: "donut", data: inlineDonutDataB, diff --git a/examples/apps/web-charts/src/sections/line.js b/examples/apps/web-charts/src/sections/line.js index 0bb77640..e8f077f0 100644 --- a/examples/apps/web-charts/src/sections/line.js +++ b/examples/apps/web-charts/src/sections/line.js @@ -20,7 +20,7 @@ export function renderLineSections(api, status) {

Line Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderChart( + ${chart.render( api.getEntity("salesLineChartComposition"), { width: 800, @@ -43,7 +43,7 @@ export function renderLineSections(api, status) {

Line Chart - Composition (No entity)

- ${chart.renderChart( + ${chart.render( { data: inlineLineData, width: 600, @@ -74,7 +74,7 @@ export function renderLineSections(api, status) { Line Chart with Brush - Recharts Style (Composition with api.getEntity) - ${chart.renderChart( + ${chart.render( api.getEntity("lineChartWithBrush"), { width: 800, @@ -106,7 +106,7 @@ export function renderLineSections(api, status) { Line Chart Multi Series - Recharts Style (Composition with api.getEntity) - ${chart.renderChart( + ${chart.render( api.getEntity("multiSeriesLineChartComposition"), { width: 800, @@ -210,7 +210,7 @@ export function renderLineSections(api, status) { Pause
- ${chart.renderChart( + ${chart.render( api.getEntity("realtimeLineChart"), { width: 800, diff --git a/examples/apps/web-charts/src/sections/pie.js b/examples/apps/web-charts/src/sections/pie.js index 98de178e..c66d5122 100644 --- a/examples/apps/web-charts/src/sections/pie.js +++ b/examples/apps/web-charts/src/sections/pie.js @@ -22,7 +22,7 @@ export function renderPieSection(api) {

Pie Chart - Recharts Style (Composition with api.getEntity)

- ${chart.renderChart( + ${chart.render( api.getEntity("categoryPieChartComposition"), { width: 500, @@ -46,7 +46,7 @@ export function renderPieSection(api) {

Pie Chart - Composition (No id #1)

- ${chart.renderChart( + ${chart.render( { type: "pie", data: inlinePieDataA, @@ -74,7 +74,7 @@ export function renderPieSection(api) {

Pie Chart - Composition (No id #2)

- ${chart.renderChart( + ${chart.render( { type: "pie", data: inlinePieDataB, From 86f6eb6f0940b5cfd6c788ef04164e64b4ec708e Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 16:01:36 +0100 Subject: [PATCH 30/34] fix: adjust area series render order so lower series stay visible on top --- packages/charts/src/cartesian/composed.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/charts/src/cartesian/composed.js b/packages/charts/src/cartesian/composed.js index 48c2f2d1..bd583f33 100644 --- a/packages/charts/src/cartesian/composed.js +++ b/packages/charts/src/cartesian/composed.js @@ -206,7 +206,8 @@ export function renderComposedChart(entity, { children, config = {} }, api) { const { orderedChildren } = sortChildrenByLayer(processedChildrenArray, { seriesFlag: ["isArea", "isBar", "isLine"], - reverseSeries: false, + // For area charts, render higher-value series first so lower ones stay visible on top + reverseSeries: inferredChartType === "area", includeBrush: hasBrush, }) From 85b0e71140b5759812483d8295da6d1e2cea41b5 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 16:16:31 +0100 Subject: [PATCH 31/34] feat: add composed chart config example mirroring composition mode --- examples/apps/web-charts/src/sections/area.js | 7 ++- .../apps/web-charts/src/store/entities.js | 48 ++++++++++++++----- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/examples/apps/web-charts/src/sections/area.js b/examples/apps/web-charts/src/sections/area.js index 0add0a04..1f007be6 100644 --- a/examples/apps/web-charts/src/sections/area.js +++ b/examples/apps/web-charts/src/sections/area.js @@ -48,7 +48,12 @@ export function renderAreaSections(api) {
-

Composed Area + Line + Bar (Composition)

+

Composed Area + Line + Bar - Config Style

+ ${api.render("composedSalesChart")} +
+ +
+

Composed Area + Line + Bar - Recharts Style (Composition)

${chart.render( { width: 800, 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, + }, ], }, From 0e6f12b0b3f12ec10f85b9fc69595ed639248f77 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 16:37:22 +0100 Subject: [PATCH 32/34] test: fix chart tests to use template render for config mode --- packages/charts/src/chart.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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() From 6e437e0fdb5660eca2af7fa29706a6a646ecba16 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 16:58:30 +0100 Subject: [PATCH 33/34] fix: replace deprecated chart.renderBarChart with chart.render after rebase --- examples/apps/web-charts/src/sections/bar.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/apps/web-charts/src/sections/bar.js b/examples/apps/web-charts/src/sections/bar.js index 08d8a078..1fcdd13b 100644 --- a/examples/apps/web-charts/src/sections/bar.js +++ b/examples/apps/web-charts/src/sections/bar.js @@ -34,7 +34,7 @@ export function renderBarSection(api) {

Bar Chart - Composition (Padding 0)

- ${chart.renderBarChart( + ${chart.render( api.getEntity("salesBarChartCompositionPadding"), { width: 800, @@ -42,13 +42,12 @@ export function renderBarSection(api) { padding: { top: 0, right: 0, bottom: 0, left: 0 }, children: [ chart.CartesianGrid({ - stroke: "#eee", strokeDasharray: "3 3", }), chart.XAxis({ dataKey: "label" }), - chart.YAxis({ width: "auto" }), + chart.YAxis(), chart.Bar({ dataKey: "value" }), - chart.Tooltip({}), + chart.Tooltip(), ], }, api, From 726079ac89c7543c63800a07404491e307754106 Mon Sep 17 00:00:00 2001 From: Vinicius Mantovani Date: Mon, 16 Mar 2026 18:03:33 +0100 Subject: [PATCH 34/34] test: fix zero-padding composition tests after rebase --- packages/charts/src/cartesian/area.test.js | 8 +++++--- packages/charts/src/cartesian/bar.test.js | 8 +++++--- packages/charts/src/cartesian/line.test.js | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) 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.test.js b/packages/charts/src/cartesian/bar.test.js index a48a724a..4e590dff 100644 --- a/packages/charts/src/cartesian/bar.test.js +++ b/packages/charts/src/cartesian/bar.test.js @@ -128,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/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, )