diff --git a/packages/malloy-render/CLAUDE.md b/packages/malloy-render/CLAUDE.md new file mode 100644 index 0000000000..3ac8af6fb0 --- /dev/null +++ b/packages/malloy-render/CLAUDE.md @@ -0,0 +1,141 @@ +# Malloy Render Package Context + +## Overview +This is the Malloy Renderer package (`@malloydata/render`), a web component for rendering Malloy query results. It provides visualization capabilities for Malloy query results and is included by default in the Malloy VSCode extension. + +## Key Features +- JS component that can be embedded in applications +- Renders Malloy query results with various visualization types +- Plugin system for custom visualizations +- Built with SolidJS/TSX and Vega for charting + +## Architecture + +### Main Entry Points +- `src/index.ts` - Main package exports +- `src/api/malloy-renderer.ts` - Core renderer API +- `src/component/render.tsx` - Main render component + +### Component Structure +- `src/component/` - SolidJS components for rendering + - `chart/` - Chart visualization components + - `table/` - Table visualization + - `dashboard/` - Dashboard layouts + - `vega/` - Vega chart integration + +### Data Processing +- `src/data_tree/` - Data transformation utilities + +### Plugin System +- `src/plugins/` - Built-in plugins (bar-chart, line-chart, etc.) +- Supports custom visualization plugins +- See `docs/plugin-*.md` for plugin documentation + +## Development Notes + +### Testing +- Stories for visual testing in `src/stories/` + +### Building +- Uses Vite for building +- TypeScript configuration in `tsconfig.json` + +### Common Tasks +When working on: +- **New visualizations**: Look at existing plugins in `src/plugins/` +- **Chart modifications**: See `src/component/chart/` and Vega integration +- **Plugin development**: Follow patterns in `src/plugins/` and refer to plugin docs + +## Important Files +- `package.json` - Dependencies and scripts +- `vite.config.mts` - Build configuration +- `DEVELOPING.md` - Development setup instructions +- `src/stories/**` - Examples of how malloy queries are written with rendering tags +- `src/api/**` - Details on how the renderer processes data from a malloy query + +## Line Chart Nested Data Support + +### Overview + +This section documents the architecture for supporting nested data in line charts. Currently, line charts only accept flat data and use Vega transforms to group by series. With this enhancement, line charts will also accept pre-nested Malloy query results. + +### Current Architecture + +- Line charts expect flat data with x, y, and series columns +- Data is grouped using Vega's facet transform (line 251 in generate-line_chart-vega-spec.ts) +- Field references are JSON-stringified arrays: `["field_name"]` +- The `walkFields` utility only traverses top-level fields + +### Nested Data Support Design + +#### 1. Deep Field Accessor Paths + +Support nested field references using array notation in tags: +- `x='["nested_field", "x_field"]'` - JSON array format for nested paths +- Maintains consistency with existing field reference format + +#### 2. Data Transformation Approach + +**Recommended: Flatten in mapMalloyData** +- Detect nested data structure in the input +- Flatten nested rows while preserving series order +- Map to existing x/y/series structure +- Benefits: + - Minimal changes to Vega spec + - Preserves series ordering from query + - Simpler initial implementation + +#### 3. Automatic Field Detection + +When no explicit tags are provided: +1. Detect single RepeatedRecordField in root (excluding those tagged with "tooltip") +2. Outer dimension → series +3. Inner dimension → x-axis +4. Inner measure → y-axis + +#### 4. Example Queries + +```malloy +# Flat query (current) +view: flat is { + group_by: x_dim, series_dim + aggregate: measure1 +} + +# Nested query (new) +view: nested is { + group_by: series_dim + nest: x_values is { + group_by: x_dim + aggregate: measure1 + } +} + +# With explicit tags +# viz=line { series=series_dim x='["x_values", "x_dim"]' y='["x_values", "measure1"]' } + +# With embedded tags +# viz=line +view: nested is { + # series + group_by: series_dim + nest: x_values is { + # x + group_by: x_dim + # y + aggregate: measure1 + } +} +``` + +### Implementation Notes + +- Phase 1: Core support with flattening approach +- Phase 2: Enhanced features (deep field walking, series preservation) +- Phase 3: Future optimization with native nested Vega handling + +### Key Files for Line Chart Nested Data + +- `get-line_chart-settings.ts`: Tag parsing and field resolution +- `generate-line_chart-vega-spec.ts`: Vega spec generation and data mapping +- `line-chart-plugin.tsx`: Main plugin entry point \ No newline at end of file diff --git a/packages/malloy-render/src/component/render.tsx b/packages/malloy-render/src/component/render.tsx index 8fb501f41b..8c282ecdf5 100644 --- a/packages/malloy-render/src/component/render.tsx +++ b/packages/malloy-render/src/component/render.tsx @@ -137,7 +137,12 @@ export function MalloyRenderInner(props: { // If size in fill mode, easiest thing would be to just recalculate entire thing // This is expensive but we can optimize later to make size responsive const rootCell = createMemo(() => { - return getDataTree(props.result, props.renderFieldMetadata); + try { + const tree = getDataTree(props.result, props.renderFieldMetadata); + return tree; + } catch (error) { + throw error; + } }); const metadata = createMemo(() => { @@ -181,8 +186,15 @@ export function MalloyRenderInner(props: { }; const tags = () => { - const modelTag = rootCell().field.modelTag; - const resultTag = rootCell().field.tag; + const cell = rootCell(); + if (!cell) { + throw new Error('Root cell is undefined'); + } + if (!cell.field) { + throw new Error('Root cell field is undefined'); + } + const modelTag = cell.field.modelTag; + const resultTag = cell.field.tag; const modelTheme = modelTag.tag('theme'); const localTheme = resultTag.tag('theme'); return { diff --git a/packages/malloy-render/src/plugins/line-chart/generate-line_chart-vega-spec.ts b/packages/malloy-render/src/plugins/line-chart/generate-line_chart-vega-spec.ts index c721c4e9e8..5bdecf08e6 100644 --- a/packages/malloy-render/src/plugins/line-chart/generate-line_chart-vega-spec.ts +++ b/packages/malloy-render/src/plugins/line-chart/generate-line_chart-vega-spec.ts @@ -39,6 +39,7 @@ import {NULL_SYMBOL, type RenderTimeStringOptions} from '@/util'; import {convertLegacyToVizTag} from '@/component/tag-utils'; import type {RenderMetadata} from '@/component/render-result-metadata'; import type {LineChartPluginInstance} from '@/plugins/line-chart/line-chart-plugin'; +import {Tag} from '@malloydata/malloy-tag'; type LineDataRecord = { x: string | number; @@ -113,10 +114,97 @@ export function generateLineChartVegaSpecV2( if (!xFieldPath) throw new Error('Malloy Line Chart: Missing x field'); if (!yFieldPath) throw new Error('Malloy Line Chart: Missing y field'); - const xField = explore.fieldAt(xFieldPath); - const xIsDateorTime = xField.isTime(); - const xIsBoolean = xField.isBoolean(); - const hasNullXValues = xField.valueSet.has(NULL_SYMBOL); + // Parse field paths to check if they're nested + const xPathArray = JSON.parse(xFieldPath); + const yPathArray = JSON.parse(yFieldPath); + const seriesPathArray = seriesFieldPath ? JSON.parse(seriesFieldPath) : null; + + const isNestedX = xPathArray.length > 1; + const isNestedY = yPathArray.length > 1; + + // For nested fields, we need to resolve them differently + let xField; + let yField; + let seriesField; + + if (isNestedX) { + // For nested x field, we can't get the field metadata until we're in the data + // For now, create a placeholder with safe defaults + xField = { + isTime: () => false, // Will be determined from actual data + isBoolean: () => false, + isDate: () => false, + valueSet: new Set(), + name: xPathArray[xPathArray.length - 1], + referenceId: xFieldPath, + timeframe: undefined, + minValue: undefined, + maxValue: undefined, + // Add additional required Field interface methods + isBasic: () => true, + isNumber: () => false, + isString: () => true, + isNest: () => false, + hasDataType: () => true, + type: () => 'string', + tag: new Tag({}) + } as unknown as Field; + } else { + xField = explore.fieldAt(xFieldPath); + } + + if (isNestedY) { + // For nested y field, create a placeholder with required Field interface methods + yField = { + name: yPathArray[yPathArray.length - 1], + referenceId: yFieldPath, + minNumber: undefined, + maxNumber: undefined, + // Add required Field interface methods + isTime: () => false, + isDate: () => false, + isBasic: () => true, + isNumber: () => true, // Assume y values are numeric for line charts + isString: () => false, + isBoolean: () => false, + isNest: () => false, + hasDataType: () => true, + type: () => 'number', + tag: new Tag({}) + } as unknown as Field; + } else { + yField = explore.fieldAt(yFieldPath); + } + + if (seriesFieldPath) { + if (seriesPathArray && seriesPathArray.length > 1) { + // Nested series field + seriesField = { + name: seriesPathArray[seriesPathArray.length - 1], + referenceId: seriesFieldPath, + valueSet: new Set(), // Will be populated from data + // Add required Field interface methods + isTime: () => false, + isDate: () => false, + isBasic: () => true, + isNumber: () => false, + isString: () => true, + isBoolean: () => false, + isNest: () => false, + hasDataType: () => true, + type: () => 'string', + tag: new Tag({}) + } as unknown as Field; + } else { + seriesField = explore.fieldAt(seriesFieldPath); + } + } else { + seriesField = null; + } + + const xIsDateorTime = !isNestedX && xField.isTime && xField.isTime(); + const xIsBoolean = !isNestedX && xField.isBoolean && xField.isBoolean(); + const hasNullXValues = !isNestedX && xField.valueSet && xField.valueSet.has(NULL_SYMBOL); const hasNullTimeValues = xIsDateorTime && hasNullXValues; const xScaling = (dataAccessor: string) => { return hasNullTimeValues @@ -124,9 +212,6 @@ export function generateLineChartVegaSpecV2( : `scale('xscale', ${dataAccessor})`; }; - const yField = explore.fieldAt(yFieldPath); - let seriesField = seriesFieldPath ? explore.fieldAt(seriesFieldPath) : null; - // Use synthetic series field for YoY mode if (settings.mode === 'yoy' && plugin.syntheticSeriesField) { seriesField = plugin.syntheticSeriesField; @@ -171,11 +256,15 @@ export function generateLineChartVegaSpecV2( let yMin = Infinity; let yMax = -Infinity; for (const name of settings.yChannel.fields) { - const field = explore.fieldAt(name); - const min = field.minNumber; - if (min !== undefined) yMin = Math.min(yMin, min); - const max = field.maxNumber; - if (max !== undefined) yMax = Math.max(yMax, max); + try { + const field = explore.fieldAt(name); + const min = field.minNumber; + if (min !== undefined) yMin = Math.min(yMin, min); + const max = field.maxNumber; + if (max !== undefined) yMax = Math.max(yMax, max); + } catch (e) { + // For nested fields, we can't get min/max at spec generation time + } } const yDomainMin = settings.zeroBaseline ? Math.min(0, yMin) : yMin; @@ -201,7 +290,7 @@ export function generateLineChartVegaSpecV2( }); // x axes across rows should auto share when distinct values <=20, unless user has explicitly set independent setting - const autoSharedX = xField.valueSet.size <= 20; + const autoSharedX = !isNestedX && xField.valueSet && xField.valueSet.size <= 20; const forceSharedX = settings.xChannel.independent === false; const forceIndependentX = settings.xChannel.independent === true && !forceSharedX; @@ -209,7 +298,7 @@ export function generateLineChartVegaSpecV2( forceSharedX || (autoSharedX && !forceIndependentX); // series legends across rows should auto share when distinct values <=20, unless user has explicitly set independent setting - const autoSharedSeries = seriesField && seriesField.valueSet.size <= 20; + const autoSharedSeries = seriesField && seriesField.valueSet && seriesField.valueSet.size <= 20; const forceSharedSeries = settings.seriesChannel.independent === false; const forceIndependentSeries = settings.seriesChannel.independent === true && !forceSharedSeries; @@ -517,7 +606,11 @@ export function generateLineChartVegaSpecV2( }; // For measure series, unpivot the measures into the series column - if (isMeasureSeries) { + // Skip this transform if data is already processed for nested measure series + const isNestedData = (xFieldPath && JSON.parse(xFieldPath).length > 1) || + (yFieldPath && JSON.parse(yFieldPath).length > 1); + + if (isMeasureSeries && !isNestedData) { // Pull the series values from the source record, then remap the names to remove __values valuesData.transform!.push({ type: 'fold', @@ -734,7 +827,7 @@ export function generateLineChartVegaSpecV2( settings.mode === 'yoy' ? // For YoY mode, calculate domain from actual data {data: 'values', field: 'x'} - : shouldShareXDomain + : shouldShareXDomain && !isNestedX ? xIsDateorTime ? [Number(xField.minValue), Number(xField.maxValue)] : xIsBoolean @@ -916,13 +1009,140 @@ export function generateLineChartVegaSpecV2( } const mapMalloyDataToChartData: MalloyDataToChartDataHandler = data => { + // Check if we're dealing with nested data + const isNestedData = + (xFieldPath && JSON.parse(xFieldPath).length > 1) || + (yFieldPath && JSON.parse(yFieldPath).length > 1); + + + // If we have nested data, we need to flatten it + if (isNestedData && data.rows.length > 0) { + // Determine which field contains the nested data + const xPathArray = xFieldPath ? JSON.parse(xFieldPath) : []; + const yPathArray = yFieldPath ? JSON.parse(yFieldPath) : []; + + + // Find the common nested field name + const nestedFieldName = xPathArray.length > 1 ? xPathArray[0] : + yPathArray.length > 1 ? yPathArray[0] : null; + + + if (nestedFieldName) { + // Flatten the nested data + const flattenedRows: any[] = []; + const localSeriesSet = new Set(); + + + data.rows.forEach(outerRow => { + const nestedCell = outerRow.column(nestedFieldName); + + + if (nestedCell.isRepeatedRecord()) { + // Get the series value from the outer row + const seriesValue = seriesField ? ( + // For non-nested series, get from outer row + seriesPathArray && seriesPathArray.length === 1 ? + outerRow.column(seriesField.name).value : + null // For nested series, we'll get it from inner rows + ) : null; + + // Check series limit + if (seriesValue !== null && seriesValue !== undefined) { + if (seriesSet && (explore.isRoot() || shouldShareSeriesDomain)) { + if (!seriesSet.has(seriesValue)) return; // Skip if not in allowed series + } else if (localSeriesSet.size >= maxSeries && !localSeriesSet.has(seriesValue)) { + return; // Skip if we've reached max series + } + localSeriesSet.add(seriesValue); + } + + // Process each nested row + nestedCell.rows.forEach((innerRow, index) => { + // Extract x value from the nested row + const xFieldName = xPathArray.length > 1 ? xPathArray[xPathArray.length - 1] : xField.name; + const xCell = innerRow.column(xFieldName); + + // Handle time values for x-axis + const xValue = xCell.isTime() && xCell.value ? xCell.value.valueOf() : xCell.value; + + // Handle multiple y fields (measure series) + if (isMeasureSeries) { + // Create one row per y field + settings.yChannel.fields.forEach(yPath => { + const yPathArray = JSON.parse(yPath); + const yFieldName = yPathArray.length > 1 ? yPathArray[yPathArray.length - 1] : explore.fieldAt(yPath).name; + const yValue = innerRow.column(yFieldName).value; + + if (yValue !== null && yValue !== undefined) { + flattenedRows.push({ + ...outerRow.allCellValues(), + __values: { + ...outerRow.allCellValues(), + [yFieldName]: yValue + }, + __row: outerRow, + __nestedIndex: index, + x: xValue ?? NULL_SYMBOL, + y: yValue, + series: yFieldName // For measure series, field name is the series + }); + } + }); + } else { + // Single y field + const yFieldName = yPathArray.length > 1 ? yPathArray[yPathArray.length - 1] : yField.name; + const yValue = innerRow.column(yFieldName).value; + + // Get series value (might be from inner row if nested) + let actualSeriesValue = seriesValue; + if (seriesFieldPath && seriesPathArray && seriesPathArray.length > 1) { + // Nested series field - get from inner row + const seriesFieldName = seriesPathArray[seriesPathArray.length - 1]; + actualSeriesValue = innerRow.column(seriesFieldName).value; + } + + // Create a flattened row + flattenedRows.push({ + ...outerRow.allCellValues(), + __row: outerRow, + __nestedIndex: index, + x: xValue ?? NULL_SYMBOL, + y: yValue, + series: actualSeriesValue ?? NULL_SYMBOL + }); + } + }); + } + }); + + // Process the flattened data similar to the original logic + const mappedData = flattenedRows + .filter(row => row.y !== null && row.y !== undefined) + .slice(0, MAX_DATA_POINTS); + + + return { + data: mappedData, + isDataLimited: flattenedRows.length > mappedData.length, + dataLimitMessage: + seriesField && data.rows.length > maxSeries + ? `Showing ${maxSeries.toLocaleString()} of ${data.rows.length.toLocaleString()} series` + : '' + }; + } + } + + // Original logic for flat data const getXValue = (row: RecordCell) => { - const cell = row.column(xField.name); + // For flat data, use the field name + const fieldName = isNestedX ? xPathArray[0] : xField.name; + const cell = row.column(fieldName); return cell.isTime() ? cell.value.valueOf() : cell.value; }; const getYoYTransformedData = (row: RecordCell) => { - const cell = row.column(xField.name); + const fieldName = isNestedX ? xPathArray[0] : xField.name; + const cell = row.column(fieldName); if (!cell.isTime() || !cell.value) return null; const date = new Date(cell.value.valueOf()); @@ -991,7 +1211,8 @@ export function generateLineChartVegaSpecV2( const yoyData = getYoYTransformedData(row); if (!yoyData) return; // Skip rows with invalid dates - const isMissingY = row.column(yField.name).value === null; + const yFieldName = isNestedY ? yPathArray[0] : yField.name; + const isMissingY = row.column(yFieldName).value === null; if (isMissingY) return; // Skip rows with missing y values // For YoY mode, manage year series separately @@ -1016,22 +1237,24 @@ export function generateLineChartVegaSpecV2( __values: row.allCellValues(), __row: row, x: yoyData.normalizedX, - y: row.column(yField.name).value, + y: row.column(yFieldName).value, series: yoyData.year, // Year becomes the series }); return; } // Normal mode processing - let seriesVal = seriesField - ? row.column(seriesField.name).value ?? NULL_SYMBOL - : yField.name; + const yFieldName = isNestedY ? yPathArray[0] : yField.name; + const seriesFieldName = seriesField ? (seriesPathArray && seriesPathArray.length > 1 ? seriesPathArray[0] : seriesField.name) : null; + let seriesVal = seriesFieldName + ? row.column(seriesFieldName).value ?? NULL_SYMBOL + : yFieldName; // Limit # of series if (skipSeries(seriesVal)) { return; } // Filter out missing metric values - const isMissingY = row.column(yField.name).value === null; + const isMissingY = row.column(yFieldName).value === null; if (isMissingY) { return; } @@ -1044,7 +1267,7 @@ export function generateLineChartVegaSpecV2( __values: row.allCellValues(), __row: row, x: getXValue(row) ?? NULL_SYMBOL, - y: row.column(yField.name).value, + y: row.column(yFieldName).value, series: seriesVal, }); }); diff --git a/packages/malloy-render/src/plugins/line-chart/get-line_chart-settings.ts b/packages/malloy-render/src/plugins/line-chart/get-line_chart-settings.ts index 2de2eba078..5d2d310f4b 100644 --- a/packages/malloy-render/src/plugins/line-chart/get-line_chart-settings.ts +++ b/packages/malloy-render/src/plugins/line-chart/get-line_chart-settings.ts @@ -8,6 +8,7 @@ import type {Tag} from '@malloydata/malloy-tag'; import type {Channel, YChannel, SeriesChannel} from '@/component/types'; import type {NestField} from '@/data_tree'; +import {Field} from '@/data_tree'; import {walkFields, deepMerge} from '@/util'; import {convertLegacyToVizTag} from '@/component/tag-utils'; import { @@ -189,7 +190,10 @@ export function getLineChartSettings( }; function getField(ref: string) { - return explore.pathTo(explore.fieldAt([ref])); + // Support dot notation for nested fields (e.g., "field1.field2") + const path = ref.split('.'); + // explore.pathTo returns a JSON stringified array path + return explore.pathTo(explore.fieldAt(path)); } // Parse top level tags @@ -248,13 +252,68 @@ export function getLineChartSettings( const measures = explore.fields.filter(f => f.wasCalculation()); - // If still no x or y, attempt to pick the best choice + // Check for nested data structure for automatic detection FIRST + // This should take precedence over flat data detection + if (xChannel.fields.length === 0 || yChannel.fields.length === 0 || seriesChannel.fields.length === 0) { + + // Find nested fields (RepeatedRecordField) that are not tagged with "tooltip" + const nestedFields = explore.fields.filter((f): f is NestField => { + return Field.isNestField(f) && !f.tag.has('tooltip'); + }); + + + // If there's exactly one nested field, use it for automatic detection + if (nestedFields.length === 1) { + const nestedField = nestedFields[0]; + + // TypeScript already knows nestedField is a NestField because of the filter above, + // but we need to help it understand this + + // Get dimensions and measures from the nested field + const nestedDimensions = nestedField.fields.filter( + f => f.isBasic() && f.wasDimension() + ); + const nestedMeasures = nestedField.fields.filter(f => f.wasCalculation()); + + + // If we still need series and have outer dimensions, use one + if (seriesChannel.fields.length === 0 && dimensions.length > 0) { + seriesChannel.fields.push(explore.pathTo(dimensions[0])); + } + + // If we still need x and have nested dimensions, use the first one + if (xChannel.fields.length === 0 && nestedDimensions.length > 0) { + // Pick date/time field first if it exists + const nestedDateTime = nestedDimensions.find(f => f.isTime()); + if (nestedDateTime) { + const xPath = explore.pathTo(nestedField.fieldAt([nestedDateTime.name])); + xChannel.fields.push(xPath); + } else { + const xPath = explore.pathTo(nestedField.fieldAt([nestedDimensions[0].name])); + xChannel.fields.push(xPath); + } + } + + // If we still need y and have nested measures, use the first numeric one + if (yChannel.fields.length === 0 && nestedMeasures.length > 0) { + const nestedNumber = nestedMeasures.find(f => f.isNumber()); + if (nestedNumber) { + const yPath = explore.pathTo(nestedField.fieldAt([nestedNumber.name])); + yChannel.fields.push(yPath); + } + } + } + } + + // If still no x or y after nested detection, attempt to pick the best choice from flat data if (xChannel.fields.length === 0) { // Pick date/time field first if it exists const dateTimeField = explore.fields.find( f => f.wasDimension() && f.isTime() ); - if (dateTimeField) xChannel.fields.push(explore.pathTo(dateTimeField)); + if (dateTimeField) { + xChannel.fields.push(explore.pathTo(dateTimeField)); + } // Pick first dimension field for x else if (dimensions.length > 0) { xChannel.fields.push(explore.pathTo(dimensions[0])); @@ -278,20 +337,26 @@ export function getLineChartSettings( } } - if (dimensions.length > 2) { + // Validation - need to check if we have valid x/y fields now, not just dimensions/measures + if (xChannel.fields.length === 0) { throw new Error( - 'Malloy Line Chart: Too many dimensions. A line chart can have at most 2 dimensions: 1 for the x axis, and 1 for the series.' + 'Malloy Line Chart: No x-axis field found. A line chart must have at least 1 dimension for the x axis.' ); } - if (dimensions.length === 0) { + if (yChannel.fields.length === 0) { throw new Error( - 'Malloy Line Chart: No dimensions found. A line chart must have at least 1 dimension for the x axis.' + 'Malloy Line Chart: No y-axis field found. A line chart must have at least 1 measure for the y axis.' ); } - if (measures.length === 0) { - throw new Error( - 'Malloy Line Chart: No measures found. A line chart must have at least 1 measure for the y axis.' - ); + + // For flat data, enforce the original constraints + const hasNestedData = explore.fields.some(f => Field.isNestField(f)); + if (!hasNestedData) { + if (dimensions.length > 2) { + throw new Error( + 'Malloy Line Chart: Too many dimensions. A line chart can have at most 2 dimensions: 1 for the x axis, and 1 for the series.' + ); + } } // Validate year-over-year mode requirements @@ -343,6 +408,7 @@ export function getLineChartSettings( } } + return { xChannel, yChannel, diff --git a/packages/malloy-render/src/plugins/line-chart/line-chart-plugin.tsx b/packages/malloy-render/src/plugins/line-chart/line-chart-plugin.tsx index a55145e6fd..4fccd179b5 100644 --- a/packages/malloy-render/src/plugins/line-chart/line-chart-plugin.tsx +++ b/packages/malloy-render/src/plugins/line-chart/line-chart-plugin.tsx @@ -112,45 +112,102 @@ export const LineChartPluginFactory: RenderPluginFactory { - if (!runtime || !vegaProps) { - throw new Error('Malloy Line Chart: missing Vega runtime'); - } - if (!props.dataColumn.isRepeatedRecord()) { - throw new Error( - 'Malloy Line Chart: data column is not a repeated record' - ); - } + try { + if (!runtime || !vegaProps) { + throw new Error('Malloy Line Chart: missing Vega runtime'); + } + if (!props.dataColumn.isRepeatedRecord()) { + throw new Error( + 'Malloy Line Chart: data column is not a repeated record' + ); + } - const mappedData = vegaProps.mapMalloyDataToChartData( - props.dataColumn - ); + const mappedData = vegaProps.mapMalloyDataToChartData( + props.dataColumn + ); - return ( - - ); + return ( + + ); + } catch (error) { + throw error; + } }, processData: (field, cell): void => { // Process all rows to calculate series stats - if (!('rows' in cell)) return; // Only process RepeatedRecordCell + if (!('rows' in cell)) { + return; // Only process RepeatedRecordCell + } const yFieldPath = settings.yChannel.fields[0]; - if (!yFieldPath) return; - const yField = field.fieldAt(yFieldPath); - if (!yField) return; + if (!yFieldPath) { + return; + } + + // Helper function to get value from a row given a field path + const getValueFromPath = (row: any, fieldPath: string | string[]): any => { + // Parse field path if it's a JSON string + let path: string[]; + if (typeof fieldPath === 'string') { + try { + // Try to parse as JSON array + const parsed = JSON.parse(fieldPath); + if (Array.isArray(parsed)) { + path = parsed; + } else { + path = [fieldPath]; + } + } catch { + // If parsing fails, treat as simple field name + path = [fieldPath]; + } + } else { + path = fieldPath; + } + + if (path.length === 1) { + // Simple field - direct access + const column = row.column(path[0]); + return column?.value; + } else { + // Nested field - navigate through the structure + // For nested fields, we need to access the nested data + // First, get the top-level nested field + const topLevelField = path[0]; + const nestedColumn = row.column(topLevelField); + + if (!nestedColumn || !('rows' in nestedColumn)) { + return undefined; + } + + // For line charts, we'll process all rows in the nested data + // This is a temporary implementation - we may need to aggregate + // For now, just take the first row to test + if (nestedColumn.rows.length > 0) { + const firstRow = nestedColumn.rows[0]; + const leafField = path[path.length - 1]; + const leafColumn = firstRow.column(leafField); + + return leafColumn?.value; + } + + return undefined; + } + }; // Handle YoY mode - create synthetic series field for years if (settings.mode === 'yoy') { @@ -163,11 +220,13 @@ export const LineChartPluginFactory: RenderPluginFactory { - vegaProps = generateLineChartVegaSpecV2(metadata, pluginInstance); + try { + vegaProps = generateLineChartVegaSpecV2(metadata, pluginInstance); + } catch (error) { + throw error; + } // TODO: should this be passed as plugin options? createLineChartPlugin(options)? // but how would you supply these options to the default plugins? diff --git a/packages/malloy-render/src/stories/line_charts.stories.malloy b/packages/malloy-render/src/stories/line_charts.stories.malloy index 6898383755..4e56e92c1f 100644 --- a/packages/malloy-render/src/stories/line_charts.stories.malloy +++ b/packages/malloy-render/src/stories/line_charts.stories.malloy @@ -470,6 +470,94 @@ source: products is duckdb.table("static/data/products.parquet") extend { aggregate: `Sales $` is sum(`Sales $`) order_by: m } + + -- #(story) + -- # viz=line + -- view: nested_line_basic is { + -- -- Outer dimension becomes series + -- group_by: department + -- -- Nested data becomes x/y + -- nest: monthly_sales is { + -- -- x + -- group_by: `month` is date_of_sale.month + -- -- y + -- aggregate: total_sales + -- limit: 12 + -- } + -- limit: 5 + -- } + + #(story) + # viz=line { series=department x='monthly_sales.dcId' y='monthly_sales.total_sales' } + view: nested_line_explicit_paths is { + group_by: department + nest: monthly_sales is { + group_by: dcId + aggregate: total_sales + limit: 12 + order_by: dcId + } + limit: 5 + } + + -- #(story) + -- # viz=line + -- view: nested_line_auto_detect is { + -- -- This should auto-detect: brand as series, dcId as x, Sales $ as y + -- group_by: brand + -- nest: by_distribution_center is { + -- group_by: dcId + -- aggregate: `Sales $` + -- order_by: dcId + -- } + -- limit: 10 + -- } + + -- #(story) + -- # viz=line + -- view: nested_line_with_tooltip is { + -- -- Department becomes series + -- group_by: department + -- -- Main nested data for line chart + -- nest: sales_trend is { + -- -- x + -- group_by: `quarter` is date_of_sale.quarter + -- -- y + -- aggregate: avg_sale is retail_price.avg() + -- limit: 8 + -- } + -- -- This nested field should be excluded from auto-detection due to tooltip tag + -- # tooltip + -- nest: category_breakdown is { + -- group_by: category + -- aggregate: total_sales + -- limit: 5 + -- } + -- limit: 3 + -- } + + -- #(story) + -- view: nested_line_comparison is { + -- group_by: title is 'Flat vs Nested Line Charts' + -- -- Traditional flat line chart + -- # viz=line + -- nest: flat_approach is { + -- group_by: dcId, department + -- aggregate: `Sales $` + -- limit: 50 + -- } + -- -- Nested approach - same data, pre-grouped + -- # viz=line + -- nest: nested_approach is { + -- group_by: department + -- nest: by_dc is { + -- group_by: dcId + -- aggregate: `Sales $` + -- order_by: dcId + -- } + -- limit: 5 + -- } + -- } } source: missing_data is duckdb.table("static/data/missing_data.csv") extend { @@ -615,4 +703,6 @@ source: random_data_nulls is duckdb.sql(""" } } + + run: products -> { group_by: distribution_center_id}