diff --git a/change/@fluentui-react-charts-41b1a74d-095a-448d-80ec-c3156292b6a2.json b/change/@fluentui-react-charts-41b1a74d-095a-448d-80ec-c3156292b6a2.json new file mode 100644 index 00000000000000..52ab66af6061b8 --- /dev/null +++ b/change/@fluentui-react-charts-41b1a74d-095a-448d-80ec-c3156292b6a2.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "(feat): Add support for vega based schema", + "packageName": "@fluentui/react-charts", + "email": "atisjai@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charts/library/config/tests.js b/packages/charts/react-charts/library/config/tests.js index 1c0ddc283c3079..fe2f790c74175f 100644 --- a/packages/charts/react-charts/library/config/tests.js +++ b/packages/charts/react-charts/library/config/tests.js @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-empty-function */ /** Jest test setup file. */ require('@testing-library/jest-dom'); +const { resetIdsForTests } = require('@fluentui/react-utilities'); + +// Reset ID counter before each test for deterministic snapshots +beforeEach(() => { + resetIdsForTests(); +}); // https://github.com/jsdom/jsdom/issues/3368 global.ResizeObserver = class ResizeObserver { diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/README.md b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/README.md new file mode 100644 index 00000000000000..2f0ffea1799202 --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/README.md @@ -0,0 +1,196 @@ +# Vega-Lite Color Scheme Examples + +This directory contains examples demonstrating Vega-Lite color scheme support in the VegaLiteSchemaAdapter. + +## Supported Color Schemes + +The adapter maps standard Vega-Lite color schemes to Fluent UI DataViz colors: + +### Fully Supported Schemes + +| Vega-Lite Scheme | Description | Fluent Mapping | +| ---------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **category10** | D3 Category10 (10 colors) | Maps to Fluent qualitative colors (lightBlue, warning, lightGreen, error, orchid, pumpkin, hotPink, disabled, gold, teal) | +| **category20** | D3 Category20 (20 colors) | Maps to Fluent qualitative color pairs with light/dark shades | +| **tableau10** | Tableau 10 (10 colors) | Maps to Fluent colors matching Tableau's palette | +| **tableau20** | Tableau 20 (20 colors) | Maps to Fluent colors with light/dark pairs | + +### Partially Supported (Fallback to Default) + +The following schemes are recognized but currently fall back to the default Fluent palette with a warning: + +- `accent`, `dark2`, `paired`, `pastel1`, `pastel2`, `set1`, `set2`, `set3` + +### Custom Color Ranges + +You can also specify custom colors using the `range` property, which takes priority over named schemes. + +## Examples + +### 1. Category10 Line Chart + +**File**: `category10-line.json` + +Multi-series line chart using the category10 scheme: + +```json +{ + "encoding": { + "color": { + "field": "category", + "type": "nominal", + "scale": { "scheme": "category10" } + } + } +} +``` + +### 2. Tableau10 Grouped Bar Chart + +**File**: `tableau10-grouped-bar.json` + +Grouped bar chart using the tableau10 scheme: + +```json +{ + "encoding": { + "color": { + "field": "product", + "type": "nominal", + "scale": { "scheme": "tableau10" } + } + } +} +``` + +### 3. Custom Color Range Donut Chart + +**File**: `custom-range-donut.json` + +Donut chart with custom color array: + +```json +{ + "encoding": { + "color": { + "field": "category", + "type": "nominal", + "scale": { + "range": ["#637cef", "#e3008c", "#2aa0a4", "#9373c0", "#13a10e"] + } + } + } +} +``` + +### 4. Category20 Stacked Bar Chart + +**File**: `category20-stacked-bar.json` + +Stacked bar chart using the category20 scheme for more colors: + +```json +{ + "encoding": { + "color": { + "field": "segment", + "type": "nominal", + "scale": { "scheme": "category20" } + } + } +} +``` + +## Color Priority + +The adapter applies colors in the following priority order: + +1. **Static color value** (`encoding.color.value`) - Highest priority +2. **Mark color** (`mark.color`) +3. **Custom range** (`encoding.color.scale.range`) +4. **Named scheme** (`encoding.color.scale.scheme`) +5. **Default Fluent palette** - Fallback + +## Implementation Details + +### VegaLiteColorAdapter + +The color mapping is implemented in `VegaLiteColorAdapter.ts`: + +```typescript +import { getVegaColor } from './VegaLiteColorAdapter'; + +// Get color for a series +const color = getVegaColor( + seriesIndex, // Series/color index + colorScheme, // e.g., 'category10' + colorRange, // Custom color array + isDarkTheme, // Light/dark theme support +); +``` + +### Scheme Mappings + +Each Vega scheme is mapped to Fluent DataViz tokens that adapt to light/dark themes: + +**Category10 Example**: + +- Vega blue (#1f77b4) → Fluent `color26` (lightBlue.shade10) +- Vega orange (#ff7f0e) → Fluent `warning` (semantic warning color) +- Vega green (#2ca02c) → Fluent `color5` (lightGreen.primary) + +**Tableau10 Example**: + +- Tableau blue (#4e79a7) → Fluent `color1` (cornflower.tint10) +- Tableau orange (#f28e2c) → Fluent `color7` (pumpkin.primary) +- Tableau red (#e15759) → Fluent `error` (semantic error color) + +## Testing + +Unit tests for color scheme support are in `VegaLiteSchemaAdapterUT.test.tsx`: + +```bash +npm test -- VegaLiteSchemaAdapterUT +``` + +**Test Coverage**: + +- ✅ category10 scheme mapping +- ✅ tableau10 scheme mapping +- ✅ category20 scheme mapping +- ✅ Custom color ranges +- ✅ Priority order (range over scheme) +- ✅ Fallback to default palette + +## Related Files + +- **VegaLiteColorAdapter.ts** - Color scheme mapping logic +- **VegaLiteSchemaAdapter.ts** - Integration into chart transformers +- **colors.ts** - Fluent DataViz palette definitions +- **VegaLiteTypes.ts** - Type definitions for scale.scheme and scale.range + +## Usage in Charts + +All chart types support color schemes: + +- ✅ LineChart +- ✅ VerticalBarChart +- ✅ VerticalStackedBarChart +- ✅ GroupedVerticalBarChart +- ✅ DonutChart +- ✅ AreaChart (inherits from LineChart) + +## Dark Theme Support + +Color mappings automatically adapt to dark theme: + +```typescript +// Light theme: Uses first color in token array +// Dark theme: Uses second color in token array (if available) + +const color = getVegaColor(0, 'category10', undefined, true); // isDarkTheme = true +``` + +Example: + +- `color11` → Light: #3c51b4 (cornflower.shade20) | Dark: #93a4f4 (cornflower.tint30) diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/category10-line.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/category10-line.json new file mode 100644 index 00000000000000..310db1bc3f1761 --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/category10-line.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Multi-series line chart with category10 color scheme", + "mark": "line", + "data": { + "values": [ + { "month": "Jan", "sales": 28, "category": "Electronics" }, + { "month": "Feb", "sales": 55, "category": "Electronics" }, + { "month": "Mar", "sales": 43, "category": "Electronics" }, + { "month": "Apr", "sales": 91, "category": "Electronics" }, + { "month": "May", "sales": 81, "category": "Electronics" }, + { "month": "Jan", "sales": 35, "category": "Clothing" }, + { "month": "Feb", "sales": 60, "category": "Clothing" }, + { "month": "Mar", "sales": 50, "category": "Clothing" }, + { "month": "Apr", "sales": 75, "category": "Clothing" }, + { "month": "May", "sales": 70, "category": "Clothing" }, + { "month": "Jan", "sales": 20, "category": "Food" }, + { "month": "Feb", "sales": 45, "category": "Food" }, + { "month": "Mar", "sales": 38, "category": "Food" }, + { "month": "Apr", "sales": 62, "category": "Food" }, + { "month": "May", "sales": 58, "category": "Food" } + ] + }, + "encoding": { + "x": { + "field": "month", + "type": "ordinal", + "axis": { "title": "Month" } + }, + "y": { + "field": "sales", + "type": "quantitative", + "axis": { "title": "Sales" } + }, + "color": { + "field": "category", + "type": "nominal", + "scale": { "scheme": "category10" }, + "legend": { "title": "Category" } + } + }, + "title": "Sales by Category (Category10 Scheme)", + "width": 600, + "height": 400 +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/category20-stacked-bar.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/category20-stacked-bar.json new file mode 100644 index 00000000000000..8acab5ed5086b3 --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/category20-stacked-bar.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Stacked bar chart with category20 color scheme", + "mark": "bar", + "data": { + "values": [ + { "quarter": "Q1", "value": 10, "segment": "Segment 1" }, + { "quarter": "Q1", "value": 15, "segment": "Segment 2" }, + { "quarter": "Q1", "value": 8, "segment": "Segment 3" }, + { "quarter": "Q1", "value": 12, "segment": "Segment 4" }, + { "quarter": "Q2", "value": 12, "segment": "Segment 1" }, + { "quarter": "Q2", "value": 18, "segment": "Segment 2" }, + { "quarter": "Q2", "value": 10, "segment": "Segment 3" }, + { "quarter": "Q2", "value": 14, "segment": "Segment 4" }, + { "quarter": "Q3", "value": 14, "segment": "Segment 1" }, + { "quarter": "Q3", "value": 20, "segment": "Segment 2" }, + { "quarter": "Q3", "value": 12, "segment": "Segment 3" }, + { "quarter": "Q3", "value": 16, "segment": "Segment 4" }, + { "quarter": "Q4", "value": 16, "segment": "Segment 1" }, + { "quarter": "Q4", "value": 22, "segment": "Segment 2" }, + { "quarter": "Q4", "value": 14, "segment": "Segment 3" }, + { "quarter": "Q4", "value": 18, "segment": "Segment 4" } + ] + }, + "encoding": { + "x": { + "field": "quarter", + "type": "nominal", + "axis": { "title": "Quarter" } + }, + "y": { + "field": "value", + "type": "quantitative", + "axis": { "title": "Value" } + }, + "color": { + "field": "segment", + "type": "nominal", + "scale": { "scheme": "category20" }, + "legend": { "title": "Segment" } + } + }, + "title": "Quarterly Values by Segment (Category20 Scheme)", + "width": 600, + "height": 400 +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/custom-range-donut.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/custom-range-donut.json new file mode 100644 index 00000000000000..6963e70e961fb5 --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/custom-range-donut.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Donut chart with custom color range", + "mark": { "type": "arc", "innerRadius": 50 }, + "data": { + "values": [ + { "category": "A", "value": 28 }, + { "category": "B", "value": 55 }, + { "category": "C", "value": 43 }, + { "category": "D", "value": 91 }, + { "category": "E", "value": 81 } + ] + }, + "encoding": { + "theta": { + "field": "value", + "type": "quantitative" + }, + "color": { + "field": "category", + "type": "nominal", + "scale": { + "range": ["#637cef", "#e3008c", "#2aa0a4", "#9373c0", "#13a10e"] + }, + "legend": { "title": "Category" } + } + }, + "title": "Distribution by Category (Custom Colors)", + "width": 400, + "height": 400 +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/tableau10-grouped-bar.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/tableau10-grouped-bar.json new file mode 100644 index 00000000000000..11a562b2da6292 --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/color-schemes/tableau10-grouped-bar.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Grouped bar chart with tableau10 color scheme", + "mark": "bar", + "data": { + "values": [ + { "category": "Q1", "value": 28, "product": "Product A" }, + { "category": "Q1", "value": 35, "product": "Product B" }, + { "category": "Q1", "value": 42, "product": "Product C" }, + { "category": "Q2", "value": 55, "product": "Product A" }, + { "category": "Q2", "value": 60, "product": "Product B" }, + { "category": "Q2", "value": 48, "product": "Product C" }, + { "category": "Q3", "value": 43, "product": "Product A" }, + { "category": "Q3", "value": 50, "product": "Product B" }, + { "category": "Q3", "value": 65, "product": "Product C" }, + { "category": "Q4", "value": 91, "product": "Product A" }, + { "category": "Q4", "value": 75, "product": "Product B" }, + { "category": "Q4", "value": 82, "product": "Product C" } + ] + }, + "encoding": { + "x": { + "field": "category", + "type": "nominal", + "axis": { "title": "Quarter" } + }, + "y": { + "field": "value", + "type": "quantitative", + "axis": { "title": "Revenue ($K)" } + }, + "color": { + "field": "product", + "type": "nominal", + "scale": { "scheme": "tableau10" }, + "legend": { "title": "Product" } + }, + "xOffset": { "field": "product" } + }, + "title": "Quarterly Revenue by Product (Tableau10 Scheme)", + "width": 600, + "height": 400 +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/README.md b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/README.md new file mode 100644 index 00000000000000..b9cac2dcc826bc --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/README.md @@ -0,0 +1,135 @@ +# Vega-Lite Validation Test Schemas + +This directory contains test schemas designed to validate the error handling and validation logic in `VegaLiteSchemaAdapter.ts`. + +## Test Cases + +### 1. Empty Data Array (test-empty-data.json) + +**Expected Behavior**: ❌ Throws Error + +``` +Error: "VegaLiteSchemaAdapter: Empty data array for LineChart" +``` + +**Description**: Tests that the adapter properly validates that data arrays are not empty before processing. + +--- + +### 2. Nested Arrays (test-nested-arrays.json) + +**Expected Behavior**: ❌ Throws Error + +``` +Error: "VegaLiteSchemaAdapter: Nested arrays not supported for field 'x'. Use flat data structures only." +``` + +**Description**: Tests that the adapter detects and rejects nested array data structures, which are not supported. + +--- + +### 3. Type Mismatch (test-type-mismatch.json) + +**Expected Behavior**: ❌ Throws Error + +``` +Error: "VegaLiteSchemaAdapter: Field 'x' marked as quantitative but contains non-numeric values" +``` + +**Description**: Tests that the adapter validates data types match the encoding type specification (quantitative fields must contain numbers). + +--- + +### 4. Bar Chart Without Categorical Axis (test-bar-no-categorical.json) + +**Expected Behavior**: ❌ Throws Error + +``` +Error: "VegaLiteSchemaAdapter: Bar charts require at least one categorical axis (nominal/ordinal) or use bin encoding for histograms" +``` + +**Description**: Tests that the adapter enforces encoding compatibility rules (bar charts need at least one categorical axis). + +--- + +### 5. Null Values (test-null-values.json) + +**Expected Behavior**: ✅ Success (Graceful Degradation) + +**Description**: Tests that the adapter gracefully handles null and undefined values by: + +- Skipping data points with null/undefined values +- Rendering only valid data points +- Not crashing or throwing errors + +**Result**: Chart should render with 4 valid data points (x=1,y=28; x=3,y=43; x=4,y=91; x=5,y=81) + +--- + +### 6. Transform Pipeline Warning (test-transform-warning.json) + +**Expected Behavior**: ⚠️ Warning (Renders with Warning) + +``` +Warning: "VegaLiteSchemaAdapter: Transform pipeline is not yet supported. Data transformations will be ignored. Apply transformations to your data before passing to the chart." +``` + +**Description**: Tests that the adapter warns users about unsupported features (transform pipeline) but still renders the chart with unfiltered data. + +**Result**: Chart should render all 5 data points with a console warning about the ignored transform. + +--- + +## Testing Instructions + +### Manual Testing + +To manually test these schemas in the Storybook: + +1. Navigate to the Vega-Lite stories in Storybook +2. Copy the content of a test schema +3. Paste into the JSON editor +4. Observe the expected behavior (error message, warning, or successful render) + +### Automated Testing + +These test cases are covered in `VegaLiteSchemaAdapterUT.test.tsx`: + +```bash +cd library +npm test -- VegaLiteSchemaAdapterUT +``` + +**Test Suites**: + +- Empty Data Validation +- Null/Undefined Value Handling +- Nested Array Detection +- Encoding Type Validation +- Encoding Compatibility Validation +- Unsupported Features Warnings + +## Validation Summary + +| Test Case | Type | Expected Result | +| -------------------------- | ------- | ----------------- | +| Empty Data Array | Error | ❌ Throws error | +| Nested Arrays | Error | ❌ Throws error | +| Type Mismatch | Error | ❌ Throws error | +| Bar Chart (No Categorical) | Error | ❌ Throws error | +| Null Values | Success | ✅ Graceful skip | +| Transform Pipeline | Warning | ⚠️ Warns, renders | + +## Related Documentation + +- **VEGA_VALIDATION_IMPLEMENTATION.md**: Full implementation details +- **VEGA_VALIDATION_IMPROVEMENTS.md**: Validation roadmap (Phase 1, 2, 3) +- **VegaLiteSchemaAdapter.ts**: Main adapter with validation logic +- **VegaLiteSchemaAdapterUT.test.tsx**: Automated unit tests + +## Notes + +- **Fail-Fast**: Critical errors (empty data, type mismatches) throw immediately +- **Graceful Degradation**: Invalid values (null, undefined, NaN) are skipped +- **User Guidance**: Clear error messages indicate what's wrong and how to fix +- **Warnings**: Unsupported features warn but don't block rendering diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-bar-no-categorical.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-bar-no-categorical.json new file mode 100644 index 00000000000000..268b26ef99e871 --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-bar-no-categorical.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Test case: Bar chart without categorical axis (should throw validation error)", + "mark": "bar", + "data": { + "values": [ + { "x": 1, "y": 28 }, + { "x": 2, "y": 55 }, + { "x": 3, "y": 43 }, + { "x": 4, "y": 91 }, + { "x": 5, "y": 81 } + ] + }, + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "axis": { "title": "X Axis (Quantitative)" } + }, + "y": { + "field": "y", + "type": "quantitative", + "axis": { "title": "Y Axis (Quantitative)" } + } + }, + "title": "Bar Chart Without Categorical Axis (Should Error)" +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-empty-data.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-empty-data.json new file mode 100644 index 00000000000000..f1b5c58774d97f --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-empty-data.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Test case: Empty data array (should throw validation error)", + "mark": "line", + "data": { + "values": [] + }, + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "axis": { "title": "X Axis" } + }, + "y": { + "field": "y", + "type": "quantitative", + "axis": { "title": "Y Axis" } + } + }, + "title": "Empty Data Test (Should Error)" +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-nested-arrays.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-nested-arrays.json new file mode 100644 index 00000000000000..77e4b49853ce9b --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-nested-arrays.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Test case: Nested arrays in data (should throw validation error)", + "mark": "line", + "data": { + "values": [ + { "x": [1, 2, 3], "y": 28 }, + { "x": [4, 5, 6], "y": 55 }, + { "x": [7, 8, 9], "y": 43 } + ] + }, + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "axis": { "title": "X Axis" } + }, + "y": { + "field": "y", + "type": "quantitative", + "axis": { "title": "Y Axis" } + } + }, + "title": "Nested Arrays Test (Should Error)" +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-null-values.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-null-values.json new file mode 100644 index 00000000000000..22867bb47bd233 --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-null-values.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Test case: Valid data with null values (should gracefully skip nulls)", + "mark": "line", + "data": { + "values": [ + { "x": 1, "y": 28 }, + { "x": 2, "y": null }, + { "x": 3, "y": 43 }, + { "x": null, "y": 55 }, + { "x": 4, "y": 91 }, + { "x": 5, "y": 81 } + ] + }, + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "axis": { "title": "X Axis" } + }, + "y": { + "field": "y", + "type": "quantitative", + "axis": { "title": "Y Axis" } + } + }, + "title": "Null Values Test (Should Skip Nulls Gracefully)", + "width": 600, + "height": 400 +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-transform-warning.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-transform-warning.json new file mode 100644 index 00000000000000..759c0b0b4c31bf --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-transform-warning.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Test case: Unsupported transform pipeline (should warn)", + "mark": "line", + "data": { + "values": [ + { "x": 1, "y": 28 }, + { "x": 2, "y": 55 }, + { "x": 3, "y": 43 }, + { "x": 4, "y": 91 }, + { "x": 5, "y": 81 } + ] + }, + "transform": [{ "filter": "datum.y > 40" }], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "axis": { "title": "X Axis" } + }, + "y": { + "field": "y", + "type": "quantitative", + "axis": { "title": "Y Axis" } + } + }, + "title": "Transform Pipeline Test (Should Warn)", + "width": 600, + "height": 400 +} diff --git a/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-type-mismatch.json b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-type-mismatch.json new file mode 100644 index 00000000000000..0b3ef815b5416e --- /dev/null +++ b/packages/charts/react-charts/library/docs/examples/declarative/vegalite/validation-tests/test-type-mismatch.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Test case: Type mismatch - quantitative with strings (should throw validation error)", + "mark": "line", + "data": { + "values": [ + { "x": "one", "y": 28 }, + { "x": "two", "y": 55 }, + { "x": "three", "y": 43 } + ] + }, + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "axis": { "title": "X Axis (Quantitative)" } + }, + "y": { + "field": "y", + "type": "quantitative", + "axis": { "title": "Y Axis" } + } + }, + "title": "Type Mismatch Test (Should Error)" +} diff --git a/packages/charts/react-charts/library/etc/react-charts.api.md b/packages/charts/react-charts/library/etc/react-charts.api.md index 3a7d51ca324bbc..cf1e0feaa9a2eb 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -1779,7 +1779,8 @@ export interface ScatterPolarSeries extends DataSeries { // @public export interface Schema { - plotlySchema: any; + plotlySchema?: any; + selectedLegends?: string[]; } // @public (undocumented) @@ -1835,6 +1836,26 @@ export interface SparklineStyles { // @public (undocumented) export const Textbox: React_2.FunctionComponent; +// @public +export const VegaDeclarativeChart: React_2.ForwardRefExoticComponent>; + +// @public +export interface VegaDeclarativeChartProps { + chartSchema: VegaSchema; + className?: string; + onSchemaChange?: (newSchema: VegaSchema) => void; + style?: React_2.CSSProperties; +} + +// @public +export type VegaLiteSpec = any; + +// @public +export interface VegaSchema { + selectedLegends?: string[]; + vegaLiteSpec: VegaLiteSpec; +} + // @public export const VerticalBarChart: React_2.FunctionComponent; diff --git a/packages/charts/react-charts/library/jest.config.js b/packages/charts/react-charts/library/jest.config.js index 20c7802a5bffd5..250f1e25e41e1a 100644 --- a/packages/charts/react-charts/library/jest.config.js +++ b/packages/charts/react-charts/library/jest.config.js @@ -1,5 +1,9 @@ // @ts-check +// Set timezone to UTC for consistent date formatting in snapshots across all environments +// This must be set before any date operations occur +process.env.TZ = 'UTC'; + /** * @type {import('@jest/types').Config.InitialOptions} */ diff --git a/packages/charts/react-charts/library/package.json b/packages/charts/react-charts/library/package.json index be2e3ad99fdc2a..6b97f131b2f301 100644 --- a/packages/charts/react-charts/library/package.json +++ b/packages/charts/react-charts/library/package.json @@ -60,7 +60,13 @@ "@types/react": ">=16.14.0 <20.0.0", "@types/react-dom": ">=16.9.0 <20.0.0", "react": ">=16.14.0 <20.0.0", - "react-dom": ">=16.14.0 <20.0.0" + "react-dom": ">=16.14.0 <20.0.0", + "vega-lite": ">=5.0.0 <7.0.0" + }, + "peerDependenciesMeta": { + "vega-lite": { + "optional": true + } }, "exports": { ".": { diff --git a/packages/charts/react-charts/library/src/VegaDeclarativeChart.ts b/packages/charts/react-charts/library/src/VegaDeclarativeChart.ts new file mode 100644 index 00000000000000..e725af67d077d1 --- /dev/null +++ b/packages/charts/react-charts/library/src/VegaDeclarativeChart.ts @@ -0,0 +1 @@ +export * from './components/VegaDeclarativeChart'; diff --git a/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx b/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx index 0bcf13ea559f9c..0ef75b45470eec 100644 --- a/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx +++ b/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { ChartTableProps } from './ChartTable.types'; import { useChartTableStyles } from './useChartTableStyles.styles'; import { tokens } from '@fluentui/react-theme'; -import * as d3 from 'd3-color'; +import { color as d3Color, rgb as d3Rgb } from 'd3-color'; import { getColorContrast } from '../../utilities/colors'; import { resolveCSSVariables } from '../../utilities/utilities'; import { ChartTitle } from '../../utilities/index'; @@ -12,12 +12,12 @@ import { useImageExport } from '../../utilities/hooks'; import { useArrowNavigationGroup } from '@fluentui/react-tabster'; function invertHexColor(hex: string): string { - const color = d3.color(hex); - if (!color) { + const parsedColor = d3Color(hex); + if (!parsedColor) { return tokens.colorNeutralForeground1!; } - const rgb = color.rgb(); - return d3.rgb(255 - rgb.r, 255 - rgb.g, 255 - rgb.b).formatHex(); + const parsedRgb = parsedColor.rgb(); + return d3Rgb(255 - parsedRgb.r, 255 - parsedRgb.g, 255 - parsedRgb.b).formatHex(); } function getSafeBackgroundColor(chartContainer: HTMLElement, foreground?: string, background?: string): string { @@ -30,8 +30,8 @@ function getSafeBackgroundColor(chartContainer: HTMLElement, foreground?: string const resolvedFg = resolveCSSVariables(chartContainer, foreground || fallbackFg); const resolvedBg = resolveCSSVariables(chartContainer, background || fallbackBg); - const fg = d3.color(resolvedFg); - const bg = d3.color(resolvedBg); + const fg = d3Color(resolvedFg); + const bg = d3Color(resolvedBg); if (!fg || !bg) { return resolvedBg; @@ -60,7 +60,7 @@ export const ChartTable: React.FunctionComponent = React.forwar const bgColorSet = new Set(); headers.forEach(header => { const bg = header?.style?.backgroundColor; - const normalized = d3.color(bg || '')?.formatHex(); + const normalized = d3Color(bg || '')?.formatHex(); if (normalized) { bgColorSet.add(normalized); } diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx index 64d934f1a7b30f..b49f1b1980d7f3 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -12,9 +12,7 @@ import { } from '@fluentui/chart-utilities'; import type { GridProperties } from './PlotlySchemaAdapter'; import { tokens, typographyStyles } from '@fluentui/react-theme'; -import { ThemeContext_unstable as V9ThemeContext } from '@fluentui/react-shared-contexts'; -import { Theme, webLightTheme } from '@fluentui/tokens'; -import * as d3Color from 'd3-color'; +import { useIsDarkTheme, useColorMapping } from './DeclarativeChartHooks'; import { correctYearMonth, @@ -96,7 +94,12 @@ export interface Schema { * Plotly schema represented as JSON object */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - plotlySchema: any; + plotlySchema?: any; + + /** + * Selected legends (used for multi-select legend interaction) + */ + selectedLegends?: string[]; } /** @@ -137,10 +140,6 @@ export interface IDeclarativeChart { exportAsImage: (opts?: ImageExportOptions) => Promise; } -const useColorMapping = () => { - const colorMap = React.useRef(new Map()); - return colorMap; -}; function renderChart( Renderer: React.ComponentType, @@ -335,18 +334,6 @@ const chartMap: ChartTypeMap = { }, }; -const useIsDarkTheme = (): boolean => { - const parentV9Theme = React.useContext(V9ThemeContext) as Theme; - const v9Theme: Theme = parentV9Theme ? parentV9Theme : webLightTheme; - - // Get background and foreground colors - const backgroundColor = d3Color.hsl(v9Theme.colorNeutralBackground1); - const foregroundColor = d3Color.hsl(v9Theme.colorNeutralForeground1); - - const isDarkTheme = backgroundColor.l < foregroundColor.l; - - return isDarkTheme; -}; /** * DeclarativeChart component. @@ -356,6 +343,7 @@ export const DeclarativeChart: React.FunctionComponent = HTMLDivElement, DeclarativeChartProps >(({ colorwayType = 'default', ...props }, forwardedRef) => { + // Default Plotly adapter path const { plotlySchema } = sanitizeJson(props.chartSchema); const chart: OutputChartType = mapFluentChart(plotlySchema); if (!chart.isValid) { diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChartHooks.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChartHooks.ts new file mode 100644 index 00000000000000..2af70cef83a04b --- /dev/null +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChartHooks.ts @@ -0,0 +1,28 @@ +'use client'; + +import * as React from 'react'; +import { ThemeContext_unstable as V9ThemeContext } from '@fluentui/react-shared-contexts'; +import { Theme, webLightTheme } from '@fluentui/tokens'; +import { hsl as d3Hsl } from 'd3-color'; + +/** + * Hook to determine if dark theme is active based on background/foreground luminance + */ +export function useIsDarkTheme(): boolean { + const parentV9Theme = React.useContext(V9ThemeContext) as Theme; + const v9Theme: Theme = parentV9Theme ? parentV9Theme : webLightTheme; + + const backgroundColor = d3Hsl(v9Theme.colorNeutralBackground1); + const foregroundColor = d3Hsl(v9Theme.colorNeutralForeground1); + + const isDarkTheme = backgroundColor.l < foregroundColor.l; + + return isDarkTheme; +} + +/** + * Hook for color mapping across charts - maintains persistent color assignments + */ +export function useColorMapping(): React.RefObject> { + return React.useRef>(new Map()); +} diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/CODE_REVIEW_OBSERVATIONS.md b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/CODE_REVIEW_OBSERVATIONS.md new file mode 100644 index 00000000000000..ec07e6b6b37bcd --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/CODE_REVIEW_OBSERVATIONS.md @@ -0,0 +1,120 @@ +# VegaDeclarativeChart Code Review Observations + +**Date:** 2026-02-16 +**Reviewer:** Principal Engineer Review +**Files:** VegaDeclarativeChart.tsx, VegaLiteSchemaAdapter.ts, VegaLiteColorAdapter.ts, VegaLiteTypes.ts + +--- + +## P0 — Must Fix + +### 1. Spec mutation happens in component render, not in adapter + +`autoCorrectEncodingTypes(spec)` is called inside `renderSingleChart` (VegaDeclarativeChart.tsx:367), +which runs during React render. This means the mutation is a component-layer concern when it should +be an adapter-layer concern. Move this into the adapter (e.g. `initializeTransformContext` and +`getChartType`) so the component never mutates specs. + +### 2. `Math.min(...values)` / `Math.max(...values)` stack overflow risk + +`computeAggregateData` (VegaLiteSchemaAdapter.ts:873-876) spreads arrays into `Math.min`/`Math.max`. +Large datasets can exceed the call stack. Replace with already-imported `d3Min`/`d3Max`. + +--- + +## P1 — Should Fix + +### 3. Dead `colorIndex` tracking in 7+ transformers + +Every transformer maintains `colorIndex: Map` and `currentColorIndex++`, but the map +is **never read**. ~50 lines of dead bookkeeping across: vertical bar, stacked bar, grouped bar, +horizontal bar, donut, heatmap, polar. Remove entirely. + +### 4. Color resolution pattern repeated ~10 times + +```ts +colorValue || markProps.color || getVegaColorFromMap(legend, colorMap, colorScheme, colorRange, isDarkTheme); +``` + +Appears verbatim in vertical bar (x3), stacked bar (x3), horizontal bar (x3), plus variations elsewhere. +Extract a `resolveColor()` helper. + +### 5. Mark type extraction repeated ~15 times + +```ts +typeof layer.mark === 'string' ? layer.mark : layer.mark?.type; +``` + +Appears in `getChartType`, `extractAnnotations`, `extractColorFillBars`, `findPrimaryLineSpec`, stacked +bar transformer, concat renderer, layer check. Extract a `getMarkType(mark)` helper. + +### 6. Dual `VegaLiteSpec` types + +VegaDeclarativeChart.tsx:46 defines `export type VegaLiteSpec = any`. +VegaLiteTypes.ts:644 defines a properly-typed `VegaLiteSpec` interface. +The adapter uses the typed one; the component exports `any`. Unify. + +### 7. Count-aggregation fallback duplicated + +Nearly identical non-numeric y-value fallback logic in both: + +- `transformVegaLiteToVerticalBarChartProps` (lines 1440-1457) +- `transformVegaLiteToVerticalStackedBarChartProps` (lines 1617-1652) + +Extract shared helper. + +### 8. Adapter imports React unnecessarily + +`VegaLiteSchemaAdapter.ts` imports `React` solely for `React.RefObject>`. +Define a `ColorMapRef` type alias to decouple pure data transformation from React. + +--- + +## P2 — Nice to Fix + +### 9. Dead / no-op functions + +- `isConcatSpec` (VegaDeclarativeChart.tsx:108-112) — marked unused with `@ts-expect-error` +- `seriesIndex++` (VegaLiteSchemaAdapter.ts:1158) — incremented, never read +- `colorField ? xKey : xKey` (VegaLiteSchemaAdapter.ts:1451) — both branches identical +- `warnUnsupportedFeatures` (lines 663-681) — all branches are comments, zero runtime behavior +- `validateEncodingCompatibility` (lines 643-657) — function body is empty; scatter `if` has no code + +### 10. Double auto-correction + +`autoCorrectEncodingTypes` runs in the component (line 367), then `validateEncodingType` inside +transformers can also auto-correct the same fields. Consolidate into one pass in the adapter. + +### 11. Shared `chartRef` across concat subcharts + +VegaDeclarativeChart.tsx:602-609 — All subcharts in hconcat/vconcat share one `chartRef`. +Only the last mount's ref survives. Consumers using `componentRef` would only control one subchart. + +### 12. `parseFloat(xValue) || 0` silently corrupts data + +VegaLiteSchemaAdapter.ts:955 — Non-parseable strings become `NaN` (falsy), then `|| 0` places them +at x=0 instead of skipping them. Should skip invalid values. + +### 13. Area chart force-cast + +VegaLiteSchemaAdapter.ts:2089 — `as AreaChartProps` suppresses type mismatches between +LineChartProps spread and AreaChartProps. If these diverge, bugs will be invisible. + +### 14. Deep nesting in stacked bar transformer + +`transformVegaLiteToVerticalStackedBarChartProps` has 4+ levels of if/else nesting. +Closing braces need comments to be parseable (`// end else`). Flatten with early returns or helpers. + +--- + +## Dismissed (by design or acceptable) + +| Item | Reason | +| ------------------------------------------- | ------------------------------------------------ | +| Color map ref threading | By design — ensures series color consistency | +| Axis ternary `...(x ? x : {})` | Intentional to avoid unnecessary object creation | +| `?? undefined` verbosity | Acceptable for readability | +| Property name typos (`Lables`, `Axistitle`) | Backward compatibility with existing Fluent API | +| Data size guard | Out of scope | +| Monolith adapter file | Acceptable for now | +| 140-line JSDoc | Acceptable documentation | diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/ReportedBugs.csv b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/ReportedBugs.csv new file mode 100644 index 00000000000000..d7fafdc47edc95 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/ReportedBugs.csv @@ -0,0 +1,26 @@ +ID,Work Item Type,Title,Assigned To,State,Tags,Description,Repro Steps +"13860","Bug","[Vega Declarative chart] Text on the chart is overlapping on the chart.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 719
3. mouse hover on the chart  and observe the chart

Actual behavior: 
Text on the chart is overlapping on the chart.

Expected behavior: 
text should be displayed properly.

Note: please find attached Video for reference

same issue observed in :382,387,465,466,516,657,719,863,
" +"13859","Bug","[Vega Declarative chart] Although the two charts use different display formats, the legend selection and deselection still apply changes to the charts.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 1104
3. mouse hover on the chart bars
4.select and deselect the legends and observe the chart display 

Actual behavior: 
Although the two charts use different display formats, the legend selection and deselection still apply changes to the charts.

Expected behavior: 
chart should be displayed properly.

Note: please find attached Video for reference


" +"13858","Bug","[Vega Declarative chart] The area covered by the chart extends beyond the x-axis (y=0).",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 753
3.  observe the chart display in the chart

Actual behavior: 
The area covered by the chart extends beyond the x-axis (y=0).

Expected behavior: 
chart should be displayed properly.

Note: please find attached Video for reference


" +"13857","Bug","[Vega Declarative chart] Bar is misplaced from the chart and displayed outside of the chart.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 753
3.  observe the chart display in the chart

Actual behavior: 
Bar is misplaced from the chart and displayed outside of the chart.

Expected behavior: 
chart should be displayed properly.

Note: please find attached Video for reference


" +"13856","Bug","[Vega Declarative chart] some of the chart numbers are displayed twice in the dropdown.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 1000
3.  observe the numbers in the chart

Actual behavior: 
some of the chart numbers are displayed twice in the dropdown.

Expected behavior: 
chart should not be displayed twice and loaded properly.

Note: please find attached Video for reference

" +"13855","Bug","[Vega Declarative chart] For some of the charts are not loading/empty, showing error and only legends loading.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 343
3.  observe the chart

Actual behavior: 
For some of the charts are not loading/empty, showing error and only legends loading.

Expected behavior: 
chart should be displayed and loaded properly.

Note: please find attached Video for reference

same issue observed in :

  1. chart not loading showing error:   344,346,355,363,383,386,391,399,400,416,424,425,427,431,432,441,488,506,539,543,544,546,548,549,551,552,554,556,560,561,562,569,622,623,624,625,626,627,628, 629,630,631,632,633,634,710,716,717,718,721,724,727,728,754,789,796,822,840, 841,842,846,848,850,851,852,868,870,896,897,898,899,900,901,902,903,904,905,906,907, 908,909,910,911,912,923,924,925,926,927,944,1107,
  2. chart showing empty nothing loading: 190,568,871 to 892, 1013,1014,1015
  3. only legends loading and chart is not loading:1023,1101


" +"13807","Bug","[Vega Declarative chart] The chart disappears when a chart number is selected.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 141
3.  observe the chart

Actual behavior: 
The chart disappears when a chart number is selected.

Expected behavior: 
chart should be displayed and loaded properly.

Note: please find attached Video for reference


" +"13806","Bug","[Vega Declarative chart] The data appears twice on mouse hover but is shown only once in the legend.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart 493
3. Mouse hover on the chart and observe the chart

Actual behavior: 
The data appears twice on mouse hover but is shown only once in the legend.

Expected behavior: 
chart and the legend should be displayed as per the data

Note: please find attached Video for reference

" +"13794","Bug","[Vega Declarative chart] When mouse hover on the chart data is not visible.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select AreaMultiseriesnostack
3. mouse hover on the chart and observe the data.

Actual behavior: 
mouse hover on the chart data is not visible.

Expected behavior: 
mouse hover data should be visible.

Note: please find attached Video for reference

same issue observed in :  Areamultiseiesnostack,46,328,360,985,986,987,1029,1110,1140,
" +"13793","Bug","[Vega Declarative chart] Legend representation is different.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select Adctrscatter
3. observe the legends displayed on the chart 

Actual behavior: 
Legend representation is different.

Expected behavior: 
legend should be aligned properly.

Note: please find attached Video for reference

same issue observed in :  bmiscatter, climatezonescatter,87, 117,163,196,356,430,433,436,439,450,448,451,452,457,460,462,467,470,475,478,480,483,485,487,491,493,494,497,498,503,514,517,529,532,534,536,541,550,555,558,604,605,617,618,737,738,739,740,741,742,743,744,745,746,747,748,749,788,794,800,801,803,819,823,828,830,833,837,838,839,854,866,913,918,919,920,928,929,930,931,932,933,934,935,936,938,976,1025,1038,1045,1080,1121,1123,1126,1130,1135,1137,1138,1152,      



" +"13792","Bug","[Vega Declarative chart] For bar charts, the Y-axis labels overlap the chart title, and deselecting the legend does not hide the legend data.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select conversional funnel, bar chart, data 004, data 013
3. select any legend and observe the deselected legend and the y axis data and observe.

Actual behavior: 
For bar charts, the Y-axis labels overlap the chart title, and deselecting the legend does not hide the legend data.

Expected behavior: 
deselected data should not be visible properly and the text should be visible properly.

Note: please find attached Video for reference

same issue observed in:
1048,1064,1106,1114,1145,1153,1156,1160,


" +"13791","Bug","[Vega Declarative chart] some of the chart numbers are missing in the chart type.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart type 
3. scroll the charts and observe the chart numbers.

Actual behavior: 
 some of the chart numbers are missing in the chart type.

Expected behavior: 
chart numbers should not miss in the middle of the chart.

Note: please find attached Video for reference


" +"13787","Bug","[Vega Declarative chart] Dark theme is not applied properly to the chart.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select any chart 
3. Apply the dark theme and observe the chart.

Actual behavior: 
Dark theme is not applied properly to the chart.

Expected behavior: 
x axis data is cutting off and text not visible completely.

Note: please find attached Video for reference


" +"13744","Bug","[Vega Declarative chart] The x-axis bar text is missing, but they appear on mouse hover.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart  ordering value ascending
3. observe the x axis text of the bars

Actual behavior: 
x axis text is missing for 2 bars.

Expected behavior: 
all bar data should be visible.

Note: please find attached Video for reference

same issue observed in:
compaignperformancecombo,91, 98, 100, 104, 106,121,137,186,187,198,202,219,224,229,242,243,254,266,269,270,274,292,299,303,304,317,319,321,324,326,345,347,351,356,372,374,495,504,508,518,527,640,641,642,643,644,646,647,648,654,658,659,661,663,667,668,674,677,751,753,772,775,779,787,802,813,817,824,843,844,845,849,861,981,982,983,984,994,995,997,999,1000,1001,1063,1093,1115,1116,1118,1150,1154,




" +"13743","Bug","[Vega Declarative chart] When all legends are deselected in the previous chart, the next chart is not shown.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart streaming viewership combo
3. deselect the legends in the above and below chart 
4.observe the chart

Actual behavior: 
When all legends are deselected in the previous chart, the next chart is not shown.

Expected behavior: 
chart should be visible.

Note: please find attached Video for reference


" +"13742","Bug","[Vega Declarative chart] Data is not visible as we mouse hover on the chart.","Thanneeru Deepika (TATA CONSULTANCY SERVICES LTD) ","Closed","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart  Multiplot Mobile App analytics.
3. mouse hover on the chart preview chart and observe 

Actual behavior: 
Data is not visible as we mouse hover on the chart.

Expected behavior: 
data should be visible.

Note: please find attached Video for reference


" +"13741","Bug","[Vega Declarative chart] legends are not working as per the selection of the legends.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select multiplot web analytics
3. select the legends and mouse hover on the chart.

Actual behavior: 
legends are not working as per the selection of the legends.

Expected behavior: 
selected legends data should only visible on the chart.

Note: please find attached Video for reference

same issue observed in: 1020

unable to select/deselect the legends: 423,565,567,1021,1022



" +"13740","Bug","[Vega Declarative chart] Text is of the chart is overlapping as we move the horizontal slider.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart   multi plot web analytics
3. move the horizontal slider and observe the text of the chart.

Actual behavior: 
Text is of the chart is overlapping as we move the horizontal slider.

Expected behavior: 
text and data should be visible.

Note: please find attached Video for reference



" +"13739","Bug","[Vega Declarative chart] Mouse hover data disappears when hovering over it.","Thanneeru Deepika (TATA CONSULTANCY SERVICES LTD) ","Closed","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart grouped bar
3. mouse hover on the chart and observe.

Actual behavior: 
Mouse hover data disappears when hovering over it.

Expected behavior: 
 data should be visible.

Note: please find attached Video for reference

" +"13738","Bug","[Vega Declarative chart] On mouse hover, the same data is displayed twice.",,"New","Test workflow bugs; VegaDeclarativechart","

","
Repro steps:
1. Click on link provided.
2. select chart ordering value ascending
3. observe the mouse hover chart data.

Actual behavior: 
On mouse hover, the same data is displayed twice.

Expected behavior: 
should be displayed only once.

Note: please find attached Video for reference

 bar chart,conversionfunnel, 4,13,14,22,31,32,41,49,50,58,62,67,68,76,77,90,91,98,100,104,106,121,142,152,175,182,186,205,221,229,237,240,242,243,244,245,246,342,345,347,348,351,374,375,384,388,434,437,453,449,456,461,486,492,518,540,637,640,641,642,643,644,646,649,660,849,857,921,977,978,979,981,983,984,995,997,998,999,1000,1026,1033,1036,1052,1053,1063,1072,1073,1074, 1077,1110,1115,1116,1118,1119, 1121,1122, 1125,1130,1133,1135,1152,


" +"13737","Bug","[Vega Declarative chart] The annotation content is missing, with just the text box shown on the chart.",,"New","Test workflow bugs; VegaDeclarativechart","


","
Repro steps:
1. Click on link provided.
2. select line  chart  Annotations 
3. observe the chart.

Actual behavior: 
The annotation content is missing, with just the text box shown on the chart.

Expected behavior: 
text should be visible on the Annotation chart.

Note: please find attached Video for reference

same issue observed on : 1097
" +"13736","Bug","[Vega Declarative chart] Legends are misaligned relative to the bar data displayed.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart  ordering value ascending
3. observe the legends

Actual behavior: 
Legends are misaligned relative to the bar data displayed.

Expected behavior: 
all the legends data should be visible as per bar data

Note: please find attached Video for reference


" +"13735","Bug","[Vega Declarative chart] On mouseover of the chart, data for deselected legends is still visible.","Atishay Jain ","Active","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart player stats scatter
3. select any legend and observe the deselected legend data 

Actual behavior: 
On mouseover of the chart, data for deselected legends is still visible.

Expected behavior: 
deselected legend data should not be visible.

Note: please find attached Video for reference

same issue observed in :
168,169,196,250,356,382,430,439,448,460,478,470,467,478,480,483,485,498,503,529,536,541,737,738,739,740,741,742,743,744,745,746,747,788,794,800,803, 819,823,828,830,833, 837,838,839, 854,866,918,919,920,928,929,930,933, 934,935,1025,1038,1045,1080,1121, 1123,1126,1130,1135,1137, 1138,1152,

" +"13734","Bug","[Vega Declarative chart] The Height in pixels remains the same even after making changes.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. click on height pixel 
3. change the pixel size and observe 

Actual behavior: 
The Height in pixels remains the same even after making changes.

Expected behavior: 
there should be change if we pixel size changes.

Note: please find attached Video for reference


" +"13733","Bug","[Vega Declarative chart] Mouseover data is misaligned and not appearing where it should.",,"New","Test workflow bugs; VegaDeclarativechart","","
Repro steps:
1. Click on link provided.
2. select chart Machine utilization heatmap
3. mouseover on the chart.

Actual behavior: 
Mouseover data is misaligned and not appearing where it should.

Expected behavior: 
data should be aligned properly and displayed.

Note: please find attached Video for reference

same issue observed in:
airqualityheatmap, Attendanceheatmap, 8,17,25,35,44,53,71,80,88,99,110,123,132,133,147,150,151,165,177,183, 192,201,213,218,238,311,366,373,402,410, 442,468,489, 490,496,499,500,523,535,570,571,572,573,574,575,576,577,578,579,580,711,713,714,715,720,722,723,750,791,809,808,811,847,893,894,895,1057,1062,1076,1087,1081,1110,1102,1124,1165,


" \ No newline at end of file diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.test.tsx b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.test.tsx new file mode 100644 index 00000000000000..86be0f72e1e49e --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.test.tsx @@ -0,0 +1,1200 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { VegaDeclarativeChart } from './VegaDeclarativeChart'; +import type { VegaDeclarativeChartProps } from './VegaDeclarativeChart'; +import { resetIdsForTests } from '@fluentui/react-utilities'; + +// Suppress console warnings for cleaner test output +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + jest.spyOn(console, 'warn').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-empty-function + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +// Reset IDs before each test to ensure consistent snapshots +beforeEach(() => { + resetIdsForTests(); +}); + +describe('VegaDeclarativeChart - Basic Rendering', () => { + it('renders with basic line chart spec', () => { + const spec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 15 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders vertical bar chart', () => { + const spec = { + mark: 'bar', + data: { + values: [ + { category: 'A', amount: 28 }, + { category: 'B', amount: 55 }, + { category: 'C', amount: 43 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'amount', type: 'quantitative' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders stacked bar chart with color encoding', () => { + const spec = { + mark: 'bar', + data: { + values: [ + { category: 'A', group: 'G1', amount: 28 }, + { category: 'A', group: 'G2', amount: 15 }, + { category: 'B', group: 'G1', amount: 55 }, + { category: 'B', group: 'G2', amount: 20 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'amount', type: 'quantitative' }, + color: { field: 'group', type: 'nominal' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders horizontal bar chart', () => { + const spec = { + mark: 'bar', + data: { + values: [ + { category: 'A', amount: 28 }, + { category: 'B', amount: 55 }, + { category: 'C', amount: 43 }, + ], + }, + encoding: { + y: { field: 'category', type: 'nominal' }, + x: { field: 'amount', type: 'quantitative' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('throws error when vegaLiteSpec is missing', () => { + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + render(); + }).toThrow('VegaDeclarativeChart: vegaLiteSpec is required'); + + consoleSpy.mockRestore(); + }); + + it('handles legend selection', () => { + const onSchemaChange = jest.fn(); + const spec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 10, category: 'A' }, + { x: 2, y: 20, category: 'B' }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + }, + }; + + render(); + // Legend interaction would be tested in integration tests + }); + + it('renders area chart', () => { + const spec = { + mark: 'area', + data: { + values: [ + { date: '2023-01', value: 100 }, + { date: '2023-02', value: 150 }, + { date: '2023-03', value: 120 }, + ], + }, + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders scatter chart', () => { + const spec = { + mark: 'point', + data: { + values: [ + { x: 10, y: 20, size: 100 }, + { x: 15, y: 30, size: 200 }, + { x: 25, y: 15, size: 150 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + size: { field: 'size', type: 'quantitative' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders donut chart', () => { + const spec = { + mark: 'arc', + data: { + values: [ + { category: 'A', value: 30 }, + { category: 'B', value: 70 }, + { category: 'C', value: 50 }, + ], + }, + encoding: { + theta: { field: 'value', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders heatmap chart', () => { + const spec = { + mark: 'rect', + data: { + values: [ + { x: 'A', y: 'Mon', value: 28 }, + { x: 'B', y: 'Mon', value: 55 }, + { x: 'C', y: 'Mon', value: 43 }, + { x: 'A', y: 'Tue', value: 91 }, + { x: 'B', y: 'Tue', value: 81 }, + { x: 'C', y: 'Tue', value: 53 }, + ], + }, + encoding: { + x: { field: 'x', type: 'nominal' }, + y: { field: 'y', type: 'nominal' }, + color: { field: 'value', type: 'quantitative' }, + }, + }; + + const { container } = render(); + expect(container).toBeTruthy(); + }); +}); + +// =================================================================================================== +// BAR + LINE COMBO CHARTS +// =================================================================================================== + +describe('VegaDeclarativeChart - Bar+Line Combo Rendering', () => { + describe('Bar + Line Combinations', () => { + it('should render bar chart with single line overlay', () => { + const spec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'month', type: 'ordinal' as const }, + y: { field: 'sales', type: 'quantitative' as const }, + color: { field: 'region', type: 'nominal' as const }, + }, + }, + { + mark: 'line', + encoding: { + x: { field: 'month', type: 'ordinal' as const }, + y: { field: 'target', type: 'quantitative' as const }, + }, + }, + ], + data: { + values: [ + { month: 'Jan', sales: 100, target: 120, region: 'North' }, + { month: 'Jan', sales: 80, target: 120, region: 'South' }, + { month: 'Feb', sales: 120, target: 130, region: 'North' }, + { month: 'Feb', sales: 90, target: 130, region: 'South' }, + { month: 'Mar', sales: 110, target: 125, region: 'North' }, + { month: 'Mar', sales: 85, target: 125, region: 'South' }, + ], + }, + }; + + const { container } = render(); + + // Should render + expect(container.firstChild).toBeTruthy(); + + // Check for SVG (chart rendered) + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + + // Snapshot the entire output + expect(container).toMatchSnapshot(); + }); + + it('should render simple bar+line without color encoding', () => { + const spec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y1', type: 'quantitative' as const }, + }, + }, + { + mark: 'line', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y2', type: 'quantitative' as const }, + }, + }, + ], + data: { + values: [ + { x: 'A', y1: 10, y2: 15 }, + { x: 'B', y1: 20, y2: 18 }, + { x: 'C', y1: 15, y2: 22 }, + ], + }, + }; + + const { container } = render(); + + expect(container.firstChild).toBeTruthy(); + expect(container.querySelector('svg')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it('should render bar+line with temporal x-axis', () => { + const spec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'date', type: 'temporal' as const }, + y: { field: 'sales', type: 'quantitative' as const }, + color: { field: 'category', type: 'nominal' as const }, + }, + }, + { + mark: { type: 'line' as const, point: true }, + encoding: { + x: { field: 'date', type: 'temporal' as const }, + y: { field: 'profit', type: 'quantitative' as const }, + }, + }, + ], + data: { + values: [ + { date: '2024-01-01', sales: 100, profit: 30, category: 'A' }, + { date: '2024-01-02', sales: 120, profit: 35, category: 'A' }, + { date: '2024-01-03', sales: 110, profit: 32, category: 'A' }, + ], + }, + }; + + const { container } = render(); + + expect(container.firstChild).toBeTruthy(); + expect(container.querySelector('svg')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it('should render the actual line_bar_combo schema from schemas folder', () => { + const lineBarComboSpec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'month', type: 'temporal' as const, axis: { title: 'Month' } }, + y: { field: 'sales', type: 'quantitative' as const, axis: { title: 'Sales ($)' } }, + color: { value: 'lightblue' }, + }, + }, + { + mark: { type: 'line' as const, point: true, color: 'red' }, + encoding: { + x: { field: 'month', type: 'temporal' as const }, + y: { field: 'profit', type: 'quantitative' as const }, + }, + }, + ], + data: { + values: [ + { month: '2024-01', sales: 45000, profit: 12000 }, + { month: '2024-02', sales: 52000, profit: 15000 }, + { month: '2024-03', sales: 48000, profit: 13500 }, + { month: '2024-04', sales: 61000, profit: 18000 }, + { month: '2024-05', sales: 58000, profit: 16500 }, + { month: '2024-06', sales: 67000, profit: 20000 }, + ], + }, + title: 'Sales and Profit Trend', + }; + + const { container } = render(); + + // Should render successfully + expect(container.firstChild).toBeTruthy(); + + // Should have SVG + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + + // Verify bars exist (rect elements for bars) + const rects = container.querySelectorAll('rect'); + expect(rects.length).toBeGreaterThan(0); + + // Verify lines exist (path elements for lines) + const paths = container.querySelectorAll('path'); + expect(paths.length).toBeGreaterThan(0); + + expect(container).toMatchSnapshot(); + }); + }); + + describe('Chart Type Detection for Bar+Line', () => { + it('should detect bar+line combo and use stacked-bar type', () => { + const spec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y1', type: 'quantitative' as const }, + color: { field: 'cat', type: 'nominal' as const }, + }, + }, + { + mark: 'line', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y2', type: 'quantitative' as const }, + }, + }, + ], + data: { values: [{ x: 'A', y1: 10, y2: 15, cat: 'C1' }] }, + }; + + // This should not throw an error + expect(() => { + render(); + }).not.toThrow(); + }); + }); + + describe('Error Cases', () => { + it('should handle bar layer without color encoding gracefully', () => { + const spec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y1', type: 'quantitative' as const }, + // No color encoding + }, + }, + { + mark: 'line', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y2', type: 'quantitative' as const }, + }, + }, + ], + data: { + values: [ + { x: 'A', y1: 10, y2: 15 }, + { x: 'B', y1: 20, y2: 18 }, + ], + }, + }; + + const { container } = render(); + + // Should still render (fallback behavior) + expect(container.firstChild).toBeTruthy(); + }); + }); +}); + +// =================================================================================================== +// CHART TYPE DETECTION +// =================================================================================================== + +describe('VegaDeclarativeChart - Chart Type Detection', () => { + describe('Grouped Bar Charts', () => { + it('should detect grouped bar chart with xOffset encoding', () => { + const spec = { + mark: 'bar', + data: { + values: [ + { quarter: 'Q1', region: 'North', sales: 45000 }, + { quarter: 'Q1', region: 'South', sales: 38000 }, + { quarter: 'Q2', region: 'North', sales: 52000 }, + { quarter: 'Q2', region: 'South', sales: 41000 }, + ], + }, + encoding: { + x: { field: 'quarter', type: 'nominal' as const }, + y: { field: 'sales', type: 'quantitative' as const }, + color: { field: 'region', type: 'nominal' as const }, + xOffset: { field: 'region' }, + }, + }; + + const { container } = render(); + + // Should render successfully without errors + expect(container.firstChild).toBeTruthy(); + + // Grouped bar chart should create SVG with bars + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + + it('should detect stacked bar chart without xOffset', () => { + const spec = { + mark: 'bar', + data: { + values: [ + { quarter: 'Q1', region: 'North', sales: 45000 }, + { quarter: 'Q1', region: 'South', sales: 38000 }, + { quarter: 'Q2', region: 'North', sales: 52000 }, + { quarter: 'Q2', region: 'South', sales: 41000 }, + ], + }, + encoding: { + x: { field: 'quarter', type: 'nominal' as const }, + y: { field: 'sales', type: 'quantitative' as const }, + color: { field: 'region', type: 'nominal' as const }, + // No xOffset - should be stacked + }, + }; + + const { container } = render(); + + // Should render successfully + expect(container.firstChild).toBeTruthy(); + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + }); + + describe('Donut Charts', () => { + it('should render donut chart with innerRadius', () => { + const spec = { + mark: { type: 'arc' as const, innerRadius: 50 }, + data: { + values: [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + { category: 'C', value: 43 }, + { category: 'D', value: 91 }, + ], + }, + encoding: { + theta: { field: 'value', type: 'quantitative' as const }, + color: { field: 'category', type: 'nominal' as const }, + }, + }; + + const { container } = render(); + + expect(container.firstChild).toBeTruthy(); + + // Donut chart should have SVG + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + + it('should render pie chart without innerRadius', () => { + const spec = { + mark: 'arc' as const, + data: { + values: [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + { category: 'C', value: 43 }, + ], + }, + encoding: { + theta: { field: 'value', type: 'quantitative' as const }, + color: { field: 'category', type: 'nominal' as const }, + }, + }; + + const { container } = render(); + + expect(container.firstChild).toBeTruthy(); + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + }); + + describe('Heatmap Charts', () => { + it('should render heatmap with rect mark and x/y/color encodings', () => { + const spec = { + mark: 'rect' as const, + data: { + values: [ + { x: 'A', y: 'Monday', value: 10 }, + { x: 'B', y: 'Monday', value: 20 }, + { x: 'C', y: 'Monday', value: 15 }, + { x: 'A', y: 'Tuesday', value: 25 }, + { x: 'B', y: 'Tuesday', value: 30 }, + { x: 'C', y: 'Tuesday', value: 22 }, + ], + }, + encoding: { + x: { field: 'x', type: 'nominal' as const }, + y: { field: 'y', type: 'nominal' as const }, + color: { field: 'value', type: 'quantitative' as const }, + }, + }; + + const { container } = render(); + + expect(container.firstChild).toBeTruthy(); + + // Heatmap should have SVG + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + }); + + describe('Snapshots for Chart Type Detection', () => { + it('should match snapshot for grouped bar chart', () => { + const spec = { + mark: 'bar', + data: { + values: [ + { quarter: 'Q1', region: 'North', sales: 45000 }, + { quarter: 'Q1', region: 'South', sales: 38000 }, + ], + }, + encoding: { + x: { field: 'quarter', type: 'nominal' as const }, + y: { field: 'sales', type: 'quantitative' as const }, + color: { field: 'region', type: 'nominal' as const }, + xOffset: { field: 'region' }, + }, + }; + + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('should match snapshot for donut chart with innerRadius', () => { + const spec = { + mark: { type: 'arc' as const, innerRadius: 50 }, + data: { + values: [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + ], + }, + encoding: { + theta: { field: 'value', type: 'quantitative' as const }, + color: { field: 'category', type: 'nominal' as const }, + }, + }; + + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it('should match snapshot for heatmap chart', () => { + const spec = { + mark: 'rect' as const, + data: { + values: [ + { x: 'A', y: 'Monday', value: 10 }, + { x: 'B', y: 'Monday', value: 20 }, + ], + }, + encoding: { + x: { field: 'x', type: 'nominal' as const }, + y: { field: 'y', type: 'nominal' as const }, + color: { field: 'value', type: 'quantitative' as const }, + }, + }; + + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + }); +}); + +// =================================================================================================== +// FINANCIAL RATIOS +// =================================================================================================== + +describe('VegaDeclarativeChart - Financial Ratios Heatmap', () => { + it('should render financial ratios heatmap without errors', () => { + const financialRatiosSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Financial ratios comparison', + data: { + values: [ + { company: 'Company A', ratio: 'Current Ratio', value: 2.1 }, + { company: 'Company A', ratio: 'Quick Ratio', value: 1.5 }, + { company: 'Company A', ratio: 'Debt-to-Equity', value: 0.8 }, + { company: 'Company A', ratio: 'ROE', value: 15.2 }, + { company: 'Company B', ratio: 'Current Ratio', value: 1.8 }, + { company: 'Company B', ratio: 'Quick Ratio', value: 1.2 }, + { company: 'Company B', ratio: 'Debt-to-Equity', value: 1.3 }, + { company: 'Company B', ratio: 'ROE', value: 12.7 }, + { company: 'Company C', ratio: 'Current Ratio', value: 2.5 }, + { company: 'Company C', ratio: 'Quick Ratio', value: 1.9 }, + { company: 'Company C', ratio: 'Debt-to-Equity', value: 0.5 }, + { company: 'Company C', ratio: 'ROE', value: 18.5 }, + ], + }, + mark: 'rect', + encoding: { + x: { field: 'company', type: 'nominal', axis: { title: 'Company' } }, + y: { field: 'ratio', type: 'nominal', axis: { title: 'Financial Ratio' } }, + color: { + field: 'value', + type: 'quantitative', + scale: { scheme: 'viridis' }, + legend: { title: 'Value' }, + }, + tooltip: [ + { field: 'company', type: 'nominal' }, + { field: 'ratio', type: 'nominal' }, + { field: 'value', type: 'quantitative', format: '.1f' }, + ], + }, + title: 'Financial Ratios Heatmap', + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: financialRatiosSpec }, + }; + + const { container } = render(); + + // Verify SVG is rendered + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + // Verify heatmap rectangles are rendered (should have 12 data points) + const rects = container.querySelectorAll('rect'); + expect(rects.length).toBeGreaterThan(0); + + expect(container).toMatchSnapshot(); + }); +}); + +// =================================================================================================== +// ISSUE FIXES +// =================================================================================================== + +describe('VegaDeclarativeChart - Issue Fixes', () => { + describe('Issue 1: Heatmap Chart Not Rendering', () => { + it('should render simple heatmap chart', () => { + const heatmapSpec = { + mark: 'rect' as const, + data: { + values: [ + { x: 'A', y: 'Monday', value: 10 }, + { x: 'B', y: 'Monday', value: 20 }, + { x: 'C', y: 'Monday', value: 15 }, + { x: 'A', y: 'Tuesday', value: 25 }, + { x: 'B', y: 'Tuesday', value: 30 }, + { x: 'C', y: 'Tuesday', value: 22 }, + { x: 'A', y: 'Wednesday', value: 18 }, + { x: 'B', y: 'Wednesday', value: 28 }, + { x: 'C', y: 'Wednesday', value: 35 }, + ], + }, + encoding: { + x: { field: 'x', type: 'nominal' as const, axis: { title: 'X Category' } }, + y: { field: 'y', type: 'nominal' as const, axis: { title: 'Day' } }, + color: { field: 'value', type: 'quantitative' as const, scale: { scheme: 'blues' } }, + }, + title: 'Heatmap Chart', + }; + + const { container } = render(); + + // Heatmap should render successfully + expect(container.firstChild).toBeTruthy(); + + // Should have SVG element + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + + it('should match snapshot for heatmap', () => { + const heatmapSpec = { + mark: 'rect' as const, + data: { + values: [ + { x: 'A', y: 'Mon', value: 10 }, + { x: 'B', y: 'Mon', value: 20 }, + ], + }, + encoding: { + x: { field: 'x', type: 'nominal' as const }, + y: { field: 'y', type: 'nominal' as const }, + color: { field: 'value', type: 'quantitative' as const }, + }, + }; + + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + }); + + describe('Issue 2: Line+Bar Combo (Now Supported!)', () => { + it('should render line+bar combo with both bars and lines', () => { + const comboSpec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'month', type: 'temporal' as const, axis: { title: 'Month' } }, + y: { field: 'sales', type: 'quantitative' as const, axis: { title: 'Sales ($)' } }, + color: { field: 'category', type: 'nominal' as const }, + }, + }, + { + mark: { type: 'line' as const, point: true, color: 'red' }, + encoding: { + x: { field: 'month', type: 'temporal' as const }, + y: { field: 'profit', type: 'quantitative' as const }, + }, + }, + ], + data: { + values: [ + { month: '2024-01', sales: 45000, profit: 12000, category: 'A' }, + { month: '2024-02', sales: 52000, profit: 15000, category: 'A' }, + { month: '2024-03', sales: 48000, profit: 13500, category: 'A' }, + ], + }, + title: 'Sales and Profit Trend', + }; + + const { container } = render(); + + // Should render successfully with both bars and lines + expect(container.firstChild).toBeTruthy(); + + // Should NOT warn about bar+line combo (it's supported now) + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should match snapshot for line+bar combo', () => { + const comboSpec = { + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y1', type: 'quantitative' as const }, + }, + }, + { + mark: 'line', + encoding: { + x: { field: 'x', type: 'ordinal' as const }, + y: { field: 'y2', type: 'quantitative' as const }, + }, + }, + ], + data: { + values: [ + { x: 'A', y1: 10, y2: 20 }, + { x: 'B', y1: 15, y2: 25 }, + ], + }, + }; + + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + }); + + describe('Heatmap Detection Edge Cases', () => { + it('should NOT detect heatmap when color is not quantitative', () => { + const spec = { + mark: 'rect' as const, + data: { values: [{ x: 'A', y: 'B', cat: 'C1' }] }, + encoding: { + x: { field: 'x', type: 'nominal' as const }, + y: { field: 'y', type: 'nominal' as const }, + color: { field: 'cat', type: 'nominal' as const }, // nominal, not quantitative + }, + }; + + const { container } = render(); + + // Should still render but as different chart type + expect(container.firstChild).toBeTruthy(); + }); + }); +}); + +// =================================================================================================== +// SCATTER & HEATMAP CHARTS +// =================================================================================================== + +describe('VegaDeclarativeChart - Scatter Charts', () => { + it('should render scatter chart with basic point encoding', () => { + const scatterSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: [ + { x: 10, y: 20, category: 'A' }, + { x: 20, y: 30, category: 'B' }, + { x: 30, y: 25, category: 'A' }, + { x: 40, y: 35, category: 'B' }, + { x: 50, y: 40, category: 'C' }, + ], + }, + mark: 'point', + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + }, + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: scatterSpec }, + }; + + const { container } = render(); + + // Check that an SVG element is rendered + expect(container.querySelector('svg')).toBeInTheDocument(); + + // Snapshot test (circles render when container has width, but in test env width may be 0) + expect(container).toMatchSnapshot(); + }); + + it('should render scatter chart with size encoding', () => { + const scatterSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: [ + { height: 160, weight: 52, bmi: 20.3, category: 'Normal' }, + { height: 165, weight: 68, bmi: 25.0, category: 'Overweight' }, + { height: 170, weight: 75, bmi: 25.9, category: 'Overweight' }, + { height: 175, weight: 70, bmi: 22.9, category: 'Normal' }, + { height: 180, weight: 95, bmi: 29.3, category: 'Overweight' }, + ], + }, + mark: 'point', + encoding: { + x: { field: 'height', type: 'quantitative' }, + y: { field: 'weight', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + size: { value: 100 }, + }, + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: scatterSpec }, + }; + + const { container } = render(); + + expect(container.querySelector('svg')).toBeInTheDocument(); + + // Snapshot test (circles render when container has width, but in test env width may be 0) + expect(container).toMatchSnapshot(); + }); + + it('should render scatter chart from actual bmi_scatter.json schema', () => { + const bmiScatterSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'BMI distribution analysis', + data: { + values: [ + { height: 160, weight: 52, bmi: 20.3, category: 'Normal' }, + { height: 165, weight: 68, bmi: 25.0, category: 'Overweight' }, + { height: 170, weight: 75, bmi: 25.9, category: 'Overweight' }, + { height: 175, weight: 70, bmi: 22.9, category: 'Normal' }, + { height: 180, weight: 95, bmi: 29.3, category: 'Overweight' }, + { height: 158, weight: 45, bmi: 18.0, category: 'Underweight' }, + { height: 172, weight: 82, bmi: 27.7, category: 'Overweight' }, + { height: 168, weight: 58, bmi: 20.5, category: 'Normal' }, + { height: 177, weight: 88, bmi: 28.1, category: 'Overweight' }, + { height: 162, weight: 48, bmi: 18.3, category: 'Underweight' }, + ], + }, + mark: 'point', + encoding: { + x: { field: 'height', type: 'quantitative', axis: { title: 'Height (cm)' } }, + y: { field: 'weight', type: 'quantitative', axis: { title: 'Weight (kg)' } }, + color: { + field: 'category', + type: 'nominal', + scale: { domain: ['Underweight', 'Normal', 'Overweight'], range: ['#ff7f0e', '#2ca02c', '#d62728'] }, + legend: { title: 'BMI Category' }, + }, + size: { value: 100 }, + tooltip: [ + { field: 'height', type: 'quantitative', title: 'Height (cm)' }, + { field: 'weight', type: 'quantitative', title: 'Weight (kg)' }, + { field: 'bmi', type: 'quantitative', title: 'BMI', format: '.1f' }, + { field: 'category', type: 'nominal', title: 'Category' }, + ], + }, + title: 'BMI Distribution Scatter', + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: bmiScatterSpec }, + }; + + const { container } = render(); + + // Verify SVG is rendered + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + // Snapshot test (circles render when container has width, but in test env width may be 0) + expect(container).toMatchSnapshot(); + }); + + it('should detect scatter chart type from point mark', () => { + const scatterSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: [{ x: 1, y: 2 }] }, + mark: 'point', + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: scatterSpec }, + }; + + const { container } = render(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); + +describe('VegaDeclarativeChart - More Heatmap Charts', () => { + it('should render heatmap with rect marks and quantitative color', () => { + const heatmapSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: [ + { x: 'A', y: 'Mon', value: 10 }, + { x: 'B', y: 'Mon', value: 20 }, + { x: 'A', y: 'Tue', value: 30 }, + { x: 'B', y: 'Tue', value: 40 }, + ], + }, + mark: 'rect', + encoding: { + x: { field: 'x', type: 'ordinal' }, + y: { field: 'y', type: 'ordinal' }, + color: { field: 'value', type: 'quantitative' }, + }, + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: heatmapSpec }, + }; + + const { container } = render(); + + // Check that an SVG element is rendered + expect(container.querySelector('svg')).toBeInTheDocument(); + + // Check for heatmap rectangles + const rects = container.querySelectorAll('rect'); + expect(rects.length).toBeGreaterThan(0); + + expect(container).toMatchSnapshot(); + }); + + it('should render heatmap from actual air_quality_heatmap.json schema', () => { + const airQualitySpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Air quality index by location', + data: { + values: [ + { city: 'New York', time: 'Morning', aqi: 45 }, + { city: 'New York', time: 'Afternoon', aqi: 62 }, + { city: 'New York', time: 'Evening', aqi: 58 }, + { city: 'Los Angeles', time: 'Morning', aqi: 85 }, + { city: 'Los Angeles', time: 'Afternoon', aqi: 95 }, + { city: 'Los Angeles', time: 'Evening', aqi: 78 }, + { city: 'Chicago', time: 'Morning', aqi: 52 }, + { city: 'Chicago', time: 'Afternoon', aqi: 68 }, + { city: 'Chicago', time: 'Evening', aqi: 61 }, + { city: 'Houston', time: 'Morning', aqi: 72 }, + { city: 'Houston', time: 'Afternoon', aqi: 88 }, + { city: 'Houston', time: 'Evening', aqi: 75 }, + ], + }, + mark: 'rect', + encoding: { + x: { field: 'time', type: 'ordinal', axis: { title: 'Time of Day' } }, + y: { field: 'city', type: 'ordinal', axis: { title: 'City' } }, + color: { + field: 'aqi', + type: 'quantitative', + scale: { scheme: 'redyellowgreen', domain: [0, 150], reverse: true }, + legend: { title: 'AQI' }, + }, + tooltip: [ + { field: 'city', type: 'ordinal' }, + { field: 'time', type: 'ordinal' }, + { field: 'aqi', type: 'quantitative', title: 'Air Quality Index' }, + ], + }, + title: 'Air Quality Index Heatmap', + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: airQualitySpec }, + }; + + const { container } = render(); + + // Verify SVG is rendered + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + // Verify heatmap rectangles are rendered + const rects = container.querySelectorAll('rect'); + expect(rects.length).toBeGreaterThan(0); + + expect(container).toMatchSnapshot(); + }); + + it('should render heatmap from actual attendance_heatmap.json schema', () => { + const attendanceSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Class attendance patterns', + data: { + values: [ + { day: 'Monday', period: 'Period 1', attendance: 92 }, + { day: 'Monday', period: 'Period 2', attendance: 89 }, + { day: 'Monday', period: 'Period 3', attendance: 87 }, + { day: 'Monday', period: 'Period 4', attendance: 85 }, + { day: 'Tuesday', period: 'Period 1', attendance: 90 }, + { day: 'Tuesday', period: 'Period 2', attendance: 88 }, + { day: 'Tuesday', period: 'Period 3', attendance: 91 }, + { day: 'Tuesday', period: 'Period 4', attendance: 86 }, + { day: 'Wednesday', period: 'Period 1', attendance: 94 }, + { day: 'Wednesday', period: 'Period 2', attendance: 92 }, + { day: 'Wednesday', period: 'Period 3', attendance: 90 }, + { day: 'Wednesday', period: 'Period 4', attendance: 88 }, + ], + }, + mark: 'rect', + encoding: { + x: { field: 'day', type: 'ordinal', axis: { title: 'Day of Week' } }, + y: { field: 'period', type: 'ordinal', axis: { title: 'Class Period' } }, + color: { + field: 'attendance', + type: 'quantitative', + scale: { scheme: 'blues' }, + legend: { title: 'Attendance %' }, + }, + tooltip: [ + { field: 'day', type: 'ordinal' }, + { field: 'period', type: 'ordinal' }, + { field: 'attendance', type: 'quantitative', title: 'Attendance %', format: '.0f' }, + ], + }, + title: 'Weekly Attendance Patterns', + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: attendanceSpec }, + }; + + const { container } = render(); + + // Verify SVG is rendered + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + // Verify heatmap rectangles are rendered + const rects = container.querySelectorAll('rect'); + expect(rects.length).toBeGreaterThan(0); + + expect(container).toMatchSnapshot(); + }); + + it('should detect heatmap chart type from rect mark with quantitative color', () => { + const heatmapSpec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: [{ x: 'A', y: 'B', value: 10 }] }, + mark: 'rect', + encoding: { + x: { field: 'x', type: 'ordinal' }, + y: { field: 'y', type: 'ordinal' }, + color: { field: 'value', type: 'quantitative' }, + }, + }; + + const props: VegaDeclarativeChartProps = { + chartSchema: { vegaLiteSpec: heatmapSpec }, + }; + + const { container } = render(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.tsx new file mode 100644 index 00000000000000..44993c3bd0a324 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.tsx @@ -0,0 +1,495 @@ +'use client'; + +import * as React from 'react'; +import { + transformVegaLiteToLineChartProps, + transformVegaLiteToVerticalBarChartProps, + transformVegaLiteToVerticalStackedBarChartProps, + transformVegaLiteToGroupedVerticalBarChartProps, + transformVegaLiteToHorizontalBarChartProps, + transformVegaLiteToAreaChartProps, + transformVegaLiteToScatterChartProps, + transformVegaLiteToDonutChartProps, + transformVegaLiteToHeatMapChartProps, + transformVegaLiteToHistogramProps, + transformVegaLiteToPolarChartProps, + getChartType, + getMarkType, +} from './VegaLiteSchemaAdapter'; +import type { VegaLiteUnitSpec, VegaLiteSpec } from './VegaLiteTypes'; +import { withResponsiveContainer } from '../ResponsiveContainer/withResponsiveContainer'; +import { LineChart } from '../LineChart/index'; +import { VerticalBarChart } from '../VerticalBarChart/index'; +import { VerticalStackedBarChart } from '../VerticalStackedBarChart/index'; +import { GroupedVerticalBarChart } from '../GroupedVerticalBarChart/index'; +import { HorizontalBarChartWithAxis } from '../HorizontalBarChartWithAxis/index'; +import { AreaChart } from '../AreaChart/index'; +import { ScatterChart } from '../ScatterChart/index'; +import { DonutChart } from '../DonutChart/index'; +import { HeatMapChart } from '../HeatMapChart/index'; +import { PolarChart } from '../PolarChart/index'; +import { useIsDarkTheme, useColorMapping } from '../DeclarativeChart/DeclarativeChartHooks'; +import type { Chart } from '../../types/index'; + +// Re-export the typed VegaLiteSpec for public API +export type { VegaLiteSpec } from './VegaLiteTypes'; + +/** + * Schema for VegaDeclarativeChart component + */ +export interface VegaSchema { + /** + * Vega-Lite specification + * + * @see https://vega.github.io/vega-lite/docs/spec.html + */ + vegaLiteSpec: VegaLiteSpec; + + /** + * Selected legends for filtering + */ + selectedLegends?: string[]; +} + +/** + * Props for VegaDeclarativeChart component + */ +export interface VegaDeclarativeChartProps { + /** + * Vega-Lite chart schema + */ + chartSchema: VegaSchema; + + /** + * Callback when schema changes (e.g., legend selection) + */ + onSchemaChange?: (newSchema: VegaSchema) => void; + + /** + * Additional CSS class name + */ + className?: string; + + /** + * Additional inline styles + */ + style?: React.CSSProperties; +} + + +/** + * Check if spec is a horizontal concatenation + */ +function isHConcatSpec(spec: VegaLiteSpec): boolean { + return spec.hconcat && Array.isArray(spec.hconcat) && spec.hconcat.length > 0; +} + +/** + * Check if spec is a vertical concatenation + */ +function isVConcatSpec(spec: VegaLiteSpec): boolean { + return spec.vconcat && Array.isArray(spec.vconcat) && spec.vconcat.length > 0; +} + +/** + * Get grid properties for concat specs + */ +function getVegaConcatGridProperties(spec: VegaLiteSpec): { + templateRows: string; + templateColumns: string; + isHorizontal: boolean; + specs: VegaLiteSpec[]; +} { + if (isHConcatSpec(spec)) { + return { + templateRows: '1fr', + templateColumns: `repeat(${spec.hconcat.length}, 1fr)`, + isHorizontal: true, + specs: spec.hconcat, + }; + } + + if (isVConcatSpec(spec)) { + return { + templateRows: `repeat(${spec.vconcat.length}, 1fr)`, + templateColumns: '1fr', + isHorizontal: false, + specs: spec.vconcat, + }; + } + + return { + templateRows: '1fr', + templateColumns: '1fr', + isHorizontal: false, + specs: [spec], + }; +} + +const ResponsiveLineChart = withResponsiveContainer(LineChart); +const ResponsiveVerticalBarChart = withResponsiveContainer(VerticalBarChart); +const ResponsiveVerticalStackedBarChart = withResponsiveContainer(VerticalStackedBarChart); +const ResponsiveGroupedVerticalBarChart = withResponsiveContainer(GroupedVerticalBarChart); +const ResponsiveHorizontalBarChartWithAxis = withResponsiveContainer(HorizontalBarChartWithAxis); +const ResponsiveAreaChart = withResponsiveContainer(AreaChart); +const ResponsiveScatterChart = withResponsiveContainer(ScatterChart); +const ResponsiveDonutChart = withResponsiveContainer(DonutChart); +const ResponsiveHeatMapChart = withResponsiveContainer(HeatMapChart); +const ResponsivePolarChart = withResponsiveContainer(PolarChart); + +/** + * Chart type mapping with transformers and renderers + * Follows the factory functor pattern from PlotlyDeclarativeChart + */ +type VegaChartTypeMap = { + line: { transformer: typeof transformVegaLiteToLineChartProps; renderer: typeof ResponsiveLineChart }; + bar: { transformer: typeof transformVegaLiteToVerticalBarChartProps; renderer: typeof ResponsiveVerticalBarChart }; + 'stacked-bar': { + transformer: typeof transformVegaLiteToVerticalStackedBarChartProps; + renderer: typeof ResponsiveVerticalStackedBarChart; + }; + 'grouped-bar': { + transformer: typeof transformVegaLiteToGroupedVerticalBarChartProps; + renderer: typeof ResponsiveGroupedVerticalBarChart; + }; + 'horizontal-bar': { + transformer: typeof transformVegaLiteToHorizontalBarChartProps; + renderer: typeof ResponsiveHorizontalBarChartWithAxis; + }; + area: { transformer: typeof transformVegaLiteToAreaChartProps; renderer: typeof ResponsiveAreaChart }; + scatter: { transformer: typeof transformVegaLiteToScatterChartProps; renderer: typeof ResponsiveScatterChart }; + donut: { transformer: typeof transformVegaLiteToDonutChartProps; renderer: typeof ResponsiveDonutChart }; + heatmap: { transformer: typeof transformVegaLiteToHeatMapChartProps; renderer: typeof ResponsiveHeatMapChart }; + histogram: { transformer: typeof transformVegaLiteToHistogramProps; renderer: typeof ResponsiveVerticalBarChart }; + polar: { transformer: typeof transformVegaLiteToPolarChartProps; renderer: typeof ResponsivePolarChart }; +}; + +const vegaChartMap: VegaChartTypeMap = { + line: { transformer: transformVegaLiteToLineChartProps, renderer: ResponsiveLineChart }, + bar: { transformer: transformVegaLiteToVerticalBarChartProps, renderer: ResponsiveVerticalBarChart }, + 'stacked-bar': { + transformer: transformVegaLiteToVerticalStackedBarChartProps, + renderer: ResponsiveVerticalStackedBarChart, + }, + 'grouped-bar': { + transformer: transformVegaLiteToGroupedVerticalBarChartProps, + renderer: ResponsiveGroupedVerticalBarChart, + }, + 'horizontal-bar': { + transformer: transformVegaLiteToHorizontalBarChartProps, + renderer: ResponsiveHorizontalBarChartWithAxis, + }, + area: { transformer: transformVegaLiteToAreaChartProps, renderer: ResponsiveAreaChart }, + scatter: { transformer: transformVegaLiteToScatterChartProps, renderer: ResponsiveScatterChart }, + donut: { transformer: transformVegaLiteToDonutChartProps, renderer: ResponsiveDonutChart }, + heatmap: { transformer: transformVegaLiteToHeatMapChartProps, renderer: ResponsiveHeatMapChart }, + histogram: { transformer: transformVegaLiteToHistogramProps, renderer: ResponsiveVerticalBarChart }, + polar: { transformer: transformVegaLiteToPolarChartProps, renderer: ResponsivePolarChart }, +}; + +interface MultiSelectLegendProps { + canSelectMultipleLegends: boolean; + onChange: (keys: string[]) => void; + selectedLegends: string[]; +} + +/** + * Renders a single Vega-Lite chart spec + */ +function renderSingleChart( + spec: VegaLiteSpec, + colorMap: React.RefObject>, + isDarkTheme: boolean, + chartRef: React.RefObject, + multiSelectLegendProps: MultiSelectLegendProps, + interactiveCommonProps: { componentRef: React.RefObject; legendProps: MultiSelectLegendProps }, +): React.ReactElement { + const chartType = getChartType(spec); + const chartConfig = vegaChartMap[chartType.type]; + + if (!chartConfig) { + throw new Error(`VegaDeclarativeChart: Unsupported chart type '${chartType.type}'`); + } + + const { transformer, renderer: ChartRenderer } = chartConfig; + const chartProps = transformer(spec, colorMap, isDarkTheme) as Record; + + return ; +} + +/** + * VegaDeclarativeChart - Render Vega-Lite specifications with Fluent UI styling + * + * Supported chart types: + * - Line charts: mark: 'line' or 'point' + * - Area charts: mark: 'area' + * - Scatter charts: mark: 'point', 'circle', or 'square' + * - Vertical bar charts: mark: 'bar' with nominal/ordinal x-axis + * - Stacked bar charts: mark: 'bar' with color encoding + * - Grouped bar charts: mark: 'bar' with color encoding (via configuration) + * - Horizontal bar charts: mark: 'bar' with nominal/ordinal y-axis + * - Donut/Pie charts: mark: 'arc' with theta encoding + * - Heatmaps: mark: 'rect' with x, y, and color (quantitative) encodings + * - Combo charts: Layered specs with bar + line marks render as VerticalStackedBarChart with line overlays + * + * Multi-plot Support: + * - Horizontal concatenation (hconcat): Multiple charts side-by-side + * - Vertical concatenation (vconcat): Multiple charts stacked vertically + * - Shared data and encoding are merged from parent spec to each subplot + * + * Limitations: + * - Most layered specifications (multiple chart types) are not fully supported + * - Bar + Line combinations ARE supported and will render properly + * - For other composite charts, only the first layer will be rendered + * - Faceting and repeat operators are not yet supported + * - Funnel charts are not a native Vega-Lite mark type. The conversion_funnel.json example + * uses a horizontal bar chart (y: nominal, x: quantitative) which is the standard way to + * represent funnel data in Vega-Lite. For specialized funnel visualizations with tapering + * shapes, consider using Plotly's native funnel chart type instead. + * + * Note: Sankey, Gantt, and Gauge charts are not standard Vega-Lite marks. + * These specialized visualizations would require custom extensions or alternative approaches. + * + * @example Line Chart + * ```tsx + * import { VegaDeclarativeChart } from '@fluentui/react-charts'; + * + * const spec = { + * mark: 'line', + * data: { values: [{ x: 1, y: 10 }, { x: 2, y: 20 }] }, + * encoding: { + * x: { field: 'x', type: 'quantitative' }, + * y: { field: 'y', type: 'quantitative' } + * } + * }; + * + * + * ``` + * + * @example Area Chart + * ```tsx + * const areaSpec = { + * mark: 'area', + * data: { values: [{ date: '2023-01', value: 100 }, { date: '2023-02', value: 150 }] }, + * encoding: { + * x: { field: 'date', type: 'temporal' }, + * y: { field: 'value', type: 'quantitative' } + * } + * }; + * + * + * ``` + * + * @example Scatter Chart + * ```tsx + * const scatterSpec = { + * mark: 'point', + * data: { values: [{ x: 10, y: 20, size: 100 }, { x: 15, y: 30, size: 200 }] }, + * encoding: { + * x: { field: 'x', type: 'quantitative' }, + * y: { field: 'y', type: 'quantitative' }, + * size: { field: 'size', type: 'quantitative' } + * } + * }; + * + * + * ``` + * + * @example Vertical Bar Chart + * ```tsx + * const barSpec = { + * mark: 'bar', + * data: { values: [{ cat: 'A', val: 28 }, { cat: 'B', val: 55 }] }, + * encoding: { + * x: { field: 'cat', type: 'nominal' }, + * y: { field: 'val', type: 'quantitative' } + * } + * }; + * + * + * ``` + * + * @example Stacked Bar Chart + * ```tsx + * const stackedSpec = { + * mark: 'bar', + * data: { values: [ + * { cat: 'A', group: 'G1', val: 28 }, + * { cat: 'A', group: 'G2', val: 15 } + * ]}, + * encoding: { + * x: { field: 'cat', type: 'nominal' }, + * y: { field: 'val', type: 'quantitative' }, + * color: { field: 'group', type: 'nominal' } + * } + * }; + * + * + * ``` + * + * @example Donut Chart + * ```tsx + * const donutSpec = { + * mark: 'arc', + * data: { values: [{ category: 'A', value: 30 }, { category: 'B', value: 70 }] }, + * encoding: { + * theta: { field: 'value', type: 'quantitative' }, + * color: { field: 'category', type: 'nominal' } + * } + * }; + * + * + * ``` + * + * @example Heatmap + * ```tsx + * const heatmapSpec = { + * mark: 'rect', + * data: { values: [ + * { x: 'A', y: 'Mon', value: 28 }, + * { x: 'B', y: 'Mon', value: 55 }, + * { x: 'A', y: 'Tue', value: 43 } + * ]}, + * encoding: { + * x: { field: 'x', type: 'nominal' }, + * y: { field: 'y', type: 'nominal' }, + * color: { field: 'value', type: 'quantitative' } + * } + * }; + * + * + * ``` + */ +export const VegaDeclarativeChart = React.forwardRef( + (props, forwardedRef) => { + const { vegaLiteSpec, selectedLegends = [] } = props.chartSchema; + + if (!vegaLiteSpec) { + throw new Error('VegaDeclarativeChart: vegaLiteSpec is required in chartSchema'); + } + + const colorMap = useColorMapping(); + const isDarkTheme = useIsDarkTheme(); + const chartRef = React.useRef(null); + + const [activeLegends, setActiveLegends] = React.useState(selectedLegends); + + const onActiveLegendsChange = (keys: string[]) => { + setActiveLegends(keys); + if (props.onSchemaChange) { + props.onSchemaChange({ vegaLiteSpec, selectedLegends: keys }); + } + }; + + React.useEffect(() => { + setActiveLegends(props.chartSchema.selectedLegends ?? []); + }, [props.chartSchema.selectedLegends]); + + const multiSelectLegendProps = { + canSelectMultipleLegends: true, + onChange: onActiveLegendsChange, + selectedLegends: activeLegends, + }; + + const interactiveCommonProps = { + componentRef: chartRef, + legendProps: multiSelectLegendProps, + }; + + try { + // Check if this is a concat spec (multiple charts side-by-side or stacked) + if (isHConcatSpec(vegaLiteSpec) || isVConcatSpec(vegaLiteSpec)) { + const gridProps = getVegaConcatGridProperties(vegaLiteSpec); + + return ( +
+ {gridProps.specs.map((subSpec: VegaLiteSpec, index: number) => { + // Merge shared data and encoding from parent spec into each subplot + const mergedSpec = { + ...subSpec, + data: subSpec.data || vegaLiteSpec.data, + encoding: { + ...(vegaLiteSpec.encoding || {}), + ...(subSpec.encoding || {}), + }, + }; + + const cellRow = gridProps.isHorizontal ? 1 : index + 1; + const cellColumn = gridProps.isHorizontal ? index + 1 : 1; + + return ( +
+ {renderSingleChart( + mergedSpec, + colorMap, + isDarkTheme, + chartRef, + multiSelectLegendProps, + interactiveCommonProps, + )} +
+ ); + })} +
+ ); + } + + // Check if this is a layered spec (composite chart) + if (vegaLiteSpec.layer && vegaLiteSpec.layer.length > 1) { + // Check if it's a supported bar+line combo + const marks = vegaLiteSpec.layer.map((layer: VegaLiteUnitSpec) => getMarkType(layer.mark)); + const hasBar = marks.includes('bar'); + const hasLine = marks.includes('line') || marks.includes('point'); + const isBarLineCombo = hasBar && hasLine; + + // Only warn for unsupported layered specs + if (!isBarLineCombo) { + // Layered specifications with multiple chart types are not fully supported. + // Only the first layer will be rendered. + } + } + + // Render single chart + const chartComponent = renderSingleChart( + vegaLiteSpec, + colorMap, + isDarkTheme, + chartRef, + multiSelectLegendProps, + interactiveCommonProps, + ); + + return ( +
+ {chartComponent} +
+ ); + } catch (error) { + throw new Error(`Failed to transform Vega-Lite spec: ${error}`); + } + }, +); + +VegaDeclarativeChart.displayName = 'VegaDeclarativeChart'; diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteColorAdapter.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteColorAdapter.ts new file mode 100644 index 00000000000000..fb6284591b872b --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteColorAdapter.ts @@ -0,0 +1,390 @@ +import { DataVizPalette, getColorFromToken, getNextColor } from '../../utilities/colors'; +import { areArraysEqual } from '../../utilities/utilities'; + +/** + * A ref-like object holding a mutable Map of legend-label to color. + * Structurally compatible with React.RefObject>. + */ +export type ColorMapRef = { readonly current: Map | null }; + +/** + * Vega-Lite Color Scheme to Fluent DataViz Palette Adapter + * + * Maps standard Vega-Lite color schemes to Fluent UI DataViz colors + * Similar to PlotlyColorAdapter but for Vega-Lite specifications + */ + +// Vega's category10 scheme (D3 Category10) +// https://vega.github.io/vega/docs/schemes/#categorical +const VEGA_CATEGORY10 = [ + '#1f77b4', // blue + '#ff7f0e', // orange + '#2ca02c', // green + '#d62728', // red + '#9467bd', // purple + '#8c564b', // brown + '#e377c2', // pink + '#7f7f7f', // gray + '#bcbd22', // olive + '#17becf', // cyan +]; + +// Vega's category20 scheme +const VEGA_CATEGORY20 = [ + '#1f77b4', + '#aec7e8', // blue shades + '#ff7f0e', + '#ffbb78', // orange shades + '#2ca02c', + '#98df8a', // green shades + '#d62728', + '#ff9896', // red shades + '#9467bd', + '#c5b0d5', // purple shades + '#8c564b', + '#c49c94', // brown shades + '#e377c2', + '#f7b6d2', // pink shades + '#7f7f7f', + '#c7c7c7', // gray shades + '#bcbd22', + '#dbdb8d', // olive shades + '#17becf', + '#9edae5', // cyan shades +]; + +// Tableau10 scheme (commonly used in Vega-Lite) +const VEGA_TABLEAU10 = [ + '#4e79a7', // blue + '#f28e2c', // orange + '#e15759', // red + '#76b7b2', // teal + '#59a14f', // green + '#edc949', // yellow + '#af7aa1', // purple + '#ff9da7', // pink + '#9c755f', // brown + '#bab0ab', // gray +]; + +// Tableau20 scheme +const VEGA_TABLEAU20 = [ + '#4e79a7', + '#a0cbe8', // blue shades + '#f28e2c', + '#ffbe7d', // orange shades + '#59a14f', + '#8cd17d', // green shades + '#b6992d', + '#f1ce63', // yellow shades + '#499894', + '#86bcb6', // teal shades + '#e15759', + '#ff9d9a', // red shades + '#79706e', + '#bab0ab', // gray shades + '#d37295', + '#fabfd2', // pink shades + '#b07aa1', + '#d4a6c8', // purple shades + '#9d7660', + '#d7b5a6', // brown shades +]; + +// Mapping from Vega category10 to Fluent DataViz tokens +const CATEGORY10_FLUENT_MAPPING: string[] = [ + DataVizPalette.color26, // blue -> lightBlue.shade10 + DataVizPalette.warning, // orange -> semantic warning + DataVizPalette.color5, // green -> lightGreen.primary + DataVizPalette.error, // red -> semantic error + DataVizPalette.color4, // purple -> orchid.tint10 + DataVizPalette.color17, // brown -> pumpkin.shade20 + DataVizPalette.color22, // pink -> hotPink.tint20 + DataVizPalette.disabled, // gray -> semantic disabled + DataVizPalette.color10, // olive/yellow-green -> gold.shade10 + DataVizPalette.color3, // cyan/teal -> teal.tint20 +]; + +// Mapping from Vega category20 to Fluent DataViz tokens +const CATEGORY20_FLUENT_MAPPING: string[] = [ + DataVizPalette.color26, + DataVizPalette.color36, // blue shades + DataVizPalette.warning, + DataVizPalette.color27, // orange shades + DataVizPalette.color5, + DataVizPalette.color15, // green shades + DataVizPalette.error, + DataVizPalette.color32, // red shades + DataVizPalette.color4, + DataVizPalette.color24, // purple shades + DataVizPalette.color17, + DataVizPalette.color37, // brown shades + DataVizPalette.color22, + DataVizPalette.color12, // pink shades + DataVizPalette.disabled, + DataVizPalette.color31, // gray shades + DataVizPalette.color10, + DataVizPalette.color30, // olive shades + DataVizPalette.color3, + DataVizPalette.color13, // cyan shades +]; + +// Mapping from Tableau10 to Fluent DataViz tokens +const TABLEAU10_FLUENT_MAPPING: string[] = [ + DataVizPalette.color1, // blue -> cornflower.tint10 + DataVizPalette.color7, // orange -> pumpkin.primary + DataVizPalette.error, // red -> semantic error + DataVizPalette.color3, // teal -> teal.tint20 + DataVizPalette.color5, // green -> lightGreen.primary + DataVizPalette.color10, // yellow -> gold.shade10 + DataVizPalette.color4, // purple -> orchid.tint10 + DataVizPalette.color2, // pink -> hotPink.primary + DataVizPalette.color17, // brown -> pumpkin.shade20 + DataVizPalette.disabled, // gray -> semantic disabled +]; + +// Mapping from Tableau20 to Fluent DataViz tokens +const TABLEAU20_FLUENT_MAPPING: string[] = [ + DataVizPalette.color1, + DataVizPalette.color11, // blue shades + DataVizPalette.color7, + DataVizPalette.color27, // orange shades + DataVizPalette.color5, + DataVizPalette.color15, // green shades + DataVizPalette.color10, + DataVizPalette.color30, // yellow shades + DataVizPalette.color3, + DataVizPalette.color13, // teal shades + DataVizPalette.error, + DataVizPalette.color32, // red shades + DataVizPalette.disabled, + DataVizPalette.color31, // gray shades + DataVizPalette.color2, + DataVizPalette.color12, // pink shades + DataVizPalette.color4, + DataVizPalette.color24, // purple shades + DataVizPalette.color17, + DataVizPalette.color37, // brown shades +]; + +/** + * Supported Vega-Lite color scheme names + */ +export type VegaColorScheme = + | 'category10' + | 'category20' + | 'category20b' + | 'category20c' + | 'tableau10' + | 'tableau20' + | 'accent' + | 'dark2' + | 'paired' + | 'pastel1' + | 'pastel2' + | 'set1' + | 'set2' + | 'set3'; + +/** + * Gets the Fluent color mapping for a given Vega-Lite color scheme + */ +function getSchemeMapping(scheme: string | undefined): string[] | undefined { + if (!scheme) { + return undefined; + } + + const schemeLower = scheme.toLowerCase(); + + switch (schemeLower) { + case 'category10': + return CATEGORY10_FLUENT_MAPPING; + case 'category20': + case 'category20b': + case 'category20c': + return CATEGORY20_FLUENT_MAPPING; + case 'tableau10': + return TABLEAU10_FLUENT_MAPPING; + case 'tableau20': + return TABLEAU20_FLUENT_MAPPING; + // For unsupported schemes, fall back to default Fluent palette + case 'accent': + case 'dark2': + case 'paired': + case 'pastel1': + case 'pastel2': + case 'set1': + case 'set2': + case 'set3': + // Color schemes not yet mapped to Fluent colors. Using default palette. + return undefined; + default: + return undefined; + } +} + +/** + * Gets a color for a series based on Vega-Lite color encoding + * + * @param index - Series index + * @param scheme - Vega-Lite color scheme name (e.g., 'category10', 'tableau10') + * @param range - Custom color range array + * @param isDarkTheme - Whether dark theme is active + * @returns Color string (hex) + */ +export function getVegaColor( + index: number, + scheme: string | undefined, + range: string[] | undefined, + isDarkTheme: boolean = false, +): string { + // Priority 1: Custom range (highest priority) + if (range && range.length > 0) { + return range[index % range.length]; + } + + // Priority 2: Named color scheme mapped to Fluent + const schemeMapping = getSchemeMapping(scheme); + if (schemeMapping) { + const token = schemeMapping[index % schemeMapping.length]; + return getColorFromToken(token, isDarkTheme); + } + + // Priority 3: Default Fluent qualitative palette + return getNextColor(index, 0, isDarkTheme); +} + +/** + * Gets a color from the color map or creates a new one based on Vega-Lite encoding + * + * @param legendLabel - Legend label for the series + * @param colorMap - Color mapping ref for consistent coloring across charts + * @param scheme - Vega-Lite color scheme name + * @param range - Custom color range array + * @param isDarkTheme - Whether dark theme is active + * @returns Color string (hex) + */ +export function getVegaColorFromMap( + legendLabel: string, + colorMap: ColorMapRef, + scheme: string | undefined, + range: string[] | undefined, + isDarkTheme: boolean = false, +): string { + // Check if color is already assigned + if (colorMap.current?.has(legendLabel)) { + return colorMap.current.get(legendLabel)!; + } + + // Assign new color based on current map size + const index = colorMap.current?.size ?? 0; + const color = getVegaColor(index, scheme, range, isDarkTheme); + + colorMap.current?.set(legendLabel, color); + return color; +} + +/** + * Sequential and diverging color scheme ramps for heatmaps and continuous color scales. + * Each ramp is a 5-point gradient matching D3/Vega defaults. + */ +const SEQUENTIAL_SCHEMES: Record = { + blues: ['#deebf7', '#9ecae1', '#4292c6', '#2171b5', '#084594'], + greens: ['#e5f5e0', '#a1d99b', '#41ab5d', '#238b45', '#005a32'], + reds: ['#fee0d2', '#fc9272', '#ef3b2c', '#cb181d', '#99000d'], + oranges: ['#feedde', '#fdbe85', '#fd8d3c', '#e6550d', '#a63603'], + purples: ['#efedf5', '#bcbddc', '#807dba', '#6a51a3', '#4a1486'], + greys: ['#f0f0f0', '#bdbdbd', '#969696', '#636363', '#252525'], + viridis: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725'], + inferno: ['#000004', '#57106e', '#bc3754', '#f98c0a', '#fcffa4'], + magma: ['#000004', '#51127c', '#b73779', '#fc8961', '#fcfdbf'], + plasma: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921'], + greenblue: ['#e0f3db', '#a8ddb5', '#4eb3d3', '#2b8cbe', '#08589e'], + yellowgreen: ['#ffffcc', '#c2e699', '#78c679', '#31a354', '#006837'], + yellowgreenblue: ['#ffffcc', '#a1dab4', '#41b6c4', '#2c7fb8', '#253494'], + redyellowgreen: ['#d73027', '#fc8d59', '#fee08b', '#91cf60', '#1a9850'], + blueorange: ['#2166ac', '#67a9cf', '#f7f7f7', '#f4a582', '#b2182b'], + redblue: ['#ca0020', '#f4a582', '#f7f7f7', '#92c5de', '#0571b0'], +}; + +/** + * Gets a sequential or diverging color ramp for heatmap rendering. + * Returns an array of `steps` interpolated colors for the given scheme name. + * + * @param scheme - Vega-Lite scheme name (e.g., 'blues', 'viridis', 'redyellowgreen') + * @param steps - Number of color stops (default: 5) + * @returns Array of hex/rgb color strings, or undefined if scheme is not recognized + */ +export function getSequentialSchemeColors(scheme: string, steps: number = 5): string[] | undefined { + const ramp = SEQUENTIAL_SCHEMES[scheme.toLowerCase()]; + if (!ramp) { + return undefined; + } + + if (steps === ramp.length) { + return [...ramp]; + } + + // Interpolate to requested number of steps + const result: string[] = []; + for (let i = 0; i < steps; i++) { + const t = steps === 1 ? 0.5 : i / (steps - 1); + const pos = t * (ramp.length - 1); + const lo = Math.floor(pos); + const hi = Math.min(lo + 1, ramp.length - 1); + const frac = pos - lo; + + if (frac === 0) { + result.push(ramp[lo]); + } else { + result.push(interpolateHexColor(ramp[lo], ramp[hi], frac)); + } + } + return result; +} + +/** + * Linearly interpolates between two hex colors + */ +function interpolateHexColor(c1: string, c2: string, t: number): string { + const r1 = parseInt(c1.slice(1, 3), 16); + const g1 = parseInt(c1.slice(3, 5), 16); + const b1 = parseInt(c1.slice(5, 7), 16); + const r2 = parseInt(c2.slice(1, 3), 16); + const g2 = parseInt(c2.slice(3, 5), 16); + const b2 = parseInt(c2.slice(5, 7), 16); + + const r = Math.round(r1 + (r2 - r1) * t); + const g = Math.round(g1 + (g2 - g1) * t); + const b = Math.round(b1 + (b2 - b1) * t); + + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +/** + * Checks if the provided range matches a standard Vega scheme + * Useful for optimizing color assignment + */ +export function isStandardVegaScheme(range: string[] | undefined): VegaColorScheme | undefined { + if (!range || range.length === 0) { + return undefined; + } + + const rangeLower = range.map(c => c.toLowerCase()); + + if (areArraysEqual(rangeLower, VEGA_CATEGORY10)) { + return 'category10'; + } + if (areArraysEqual(rangeLower, VEGA_CATEGORY20)) { + return 'category20'; + } + if (areArraysEqual(rangeLower, VEGA_TABLEAU10)) { + return 'tableau10'; + } + if (areArraysEqual(rangeLower, VEGA_TABLEAU20)) { + return 'tableau20'; + } + + return undefined; +} + diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapter.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapter.ts new file mode 100644 index 00000000000000..54653b6a986ddf --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapter.ts @@ -0,0 +1,3204 @@ +// Using custom VegaLiteTypes for internal adapter logic +// For public API, VegaDeclarativeChart accepts vega-lite's TopLevelSpec +import type { + VegaLiteSpec, + VegaLiteUnitSpec, + VegaLiteMarkDef, + VegaLiteData, + VegaLiteInterpolate, + VegaLiteType, + VegaLiteEncoding, + VegaLiteSort, + VegaLiteTitleParams, +} from './VegaLiteTypes'; +import type { LineChartProps } from '../LineChart/index'; +import type { VerticalBarChartProps } from '../VerticalBarChart/index'; +import type { VerticalStackedBarChartProps } from '../VerticalStackedBarChart/index'; +import type { GroupedVerticalBarChartProps } from '../GroupedVerticalBarChart/index'; +import type { HorizontalBarChartWithAxisProps } from '../HorizontalBarChartWithAxis/index'; +import type { AreaChartProps } from '../AreaChart/index'; +import type { DonutChartProps } from '../DonutChart/index'; +import type { ScatterChartProps } from '../ScatterChart/index'; +import type { HeatMapChartProps } from '../HeatMapChart/index'; +import type { PolarChartProps, PolarAxisProps } from '../PolarChart/index'; +import type { + ChartProps, + LineChartPoints, + LineChartDataPoint, + VerticalBarChartDataPoint, + VerticalStackedChartProps, + HorizontalBarChartWithAxisDataPoint, + ChartDataPoint, + ScatterChartDataPoint, + HeatMapChartData, + HeatMapChartDataPoint, + ChartAnnotation, + LineDataInVerticalStackedBarChart, + AxisCategoryOrder, + PolarDataPoint, + LinePolarSeries, + AreaPolarSeries, + ScatterPolarSeries, + LineChartLineOptions, +} from '../../types/index'; +import type { ColorFillBarsProps } from '../LineChart/index'; +import type { Legend, LegendsProps } from '../Legends/index'; +import type { TitleStyles } from '../../utilities/Common.styles'; +import { getVegaColorFromMap, getVegaColor, getSequentialSchemeColors } from './VegaLiteColorAdapter'; +import type { ColorMapRef } from './VegaLiteColorAdapter'; +import { bin as d3Bin, extent as d3Extent, sum as d3Sum, min as d3Min, max as d3Max, mean as d3Mean } from 'd3-array'; +import type { Bin } from 'd3-array'; +import { format as d3Format } from 'd3-format'; +import { isInvalidValue } from '@fluentui/chart-utilities'; + +/** + * Vega-Lite to Fluent Charts adapter for line/point charts. + * + * Transforms Vega-Lite JSON specifications into Fluent LineChart props. + * Supports basic line charts with temporal/quantitative axes and color-encoded series. + * + * TODO: Future enhancements + * - Multi-view layouts (facet, concat, repeat) + * - Selection interactions + * - Remote data loading (url) + * - Transform pipeline (filter, aggregate, calculate) + * - Conditional encodings + * - Additional mark types (area, bar, etc.) + * - Tooltip customization + */ + +/** + * Default configuration values for VegaLite charts + */ +const DEFAULT_CHART_HEIGHT = 350; +const DEFAULT_MAX_BAR_WIDTH = 50; +const DEFAULT_TRUNCATE_CHARS = 20; + +/** + * Determines if a spec is a layered specification + */ +function isLayerSpec(spec: VegaLiteSpec): spec is VegaLiteSpec & { layer: VegaLiteUnitSpec[] } { + return Array.isArray(spec.layer) && spec.layer.length > 0; +} + +/** + * Determines if a spec is a single unit specification + */ +function isUnitSpec(spec: VegaLiteSpec): boolean { + return spec.mark !== undefined && spec.encoding !== undefined; +} + +/** + * Extracts the mark type string from a VegaLiteMarkDef (string or object with type property) + */ +export function getMarkType(mark: VegaLiteMarkDef | string | undefined): string | undefined { + if (!mark) { + return undefined; + } + return typeof mark === 'string' ? mark : (mark as { type?: string }).type; +} + +/** + * Resolves the color for a legend label using the priority chain: + * 1. Explicit color value from encoding + * 2. Mark-level color + * 3. Cached color from the shared color map + * 4. New color via local index for deterministic per-chart assignment + */ +function resolveColor( + legend: string, + index: number, + colorValue: string | undefined, + markColor: string | undefined, + colorMap: ColorMapRef, + colorScheme: string | undefined, + colorRange: string[] | undefined, + isDarkTheme?: boolean, +): string { + if (colorValue) { + return colorValue; + } + if (markColor) { + return markColor; + } + // Check colorMap cache first for cross-chart consistency + if (colorMap.current?.has(legend)) { + return colorMap.current.get(legend)!; + } + // Use local index (not colorMap.size) for deterministic per-chart color assignment + const color = getVegaColor(index, colorScheme, colorRange, isDarkTheme); + colorMap.current?.set(legend, color); + return color; +} + +/** + * Extracts inline data values from a Vega-Lite data specification + * TODO: Add support for URL-based data loading + * TODO: Add support for named dataset resolution + * TODO: Add support for data format parsing (csv, tsv) + */ +function extractDataValues(data: VegaLiteData | undefined): Array> { + if (!data) { + return []; + } + + if (data.values && Array.isArray(data.values)) { + return data.values; + } + + // TODO: Handle data.url - load remote data + if (data.url) { + // Remote data URLs are not yet supported + return []; + } + + // TODO: Handle data.name - resolve named datasets + if (data.name) { + // Named datasets are not yet supported + return []; + } + + return []; +} + +/** + * Applies a fold transform to convert wide-format data to long-format + * The fold transform unpivots specified fields into key-value pairs + * + * @param data - Array of data records in wide format + * @param foldFields - Array of field names to fold + * @param asFields - [keyName, valueName] for the new columns (defaults to ['key', 'value']) + * @returns Array of data records in long format + */ +function applyFoldTransform( + data: Array>, + foldFields: string[], + asFields: [string, string] = ['key', 'value'], +): Array> { + const [keyField, valueField] = asFields; + const result: Array> = []; + + for (const row of data) { + // Create a base row without the fields being folded + const baseRow: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (!foldFields.includes(key)) { + baseRow[key] = value; + } + } + + // Create a new row for each folded field + for (const field of foldFields) { + if (field in row) { + result.push({ + ...baseRow, + [keyField]: field, + [valueField]: row[field], + }); + } + } + } + + return result; +} + +/** + * Applies transforms from a Vega-Lite spec to data + * Currently supports: fold transform + * + * @param data - Array of data records + * @param transforms - Array of Vega-Lite transform specifications + * @returns Transformed data array + */ +function applyTransforms( + data: Array>, + transforms: Array> | undefined, +): Array> { + if (!transforms || transforms.length === 0) { + return data; + } + + let result = data; + + for (const transform of transforms) { + // Handle fold transform + if ('fold' in transform && Array.isArray(transform.fold)) { + const foldFields = transform.fold as string[]; + const asFields = (transform.as as [string, string]) || ['key', 'value']; + result = applyFoldTransform(result, foldFields, asFields); + } + // Additional transforms can be added here (filter, calculate, aggregate, etc.) + } + + return result; +} + +/** + * Normalizes a Vega-Lite spec into an array of unit specs with resolved data and encoding + * Handles both single-view and layered specifications + */ +function normalizeSpec(spec: VegaLiteSpec): VegaLiteUnitSpec[] { + if (isLayerSpec(spec)) { + // Layered spec: merge shared data and encoding with each layer + const sharedData = spec.data; + const sharedEncoding = spec.encoding; + + return spec.layer.map(layer => ({ + ...layer, + data: layer.data || sharedData, + encoding: { + ...sharedEncoding, + ...layer.encoding, + }, + })); + } + + if (isUnitSpec(spec)) { + // Single unit spec + return [ + { + mark: spec.mark!, + encoding: spec.encoding, + data: spec.data, + }, + ]; + } + + // Unsupported spec structure + return []; +} + +/** + * Parses a value to a Date if it's temporal, otherwise returns as number or string + */ +function parseValue(value: unknown, isTemporalType: boolean): Date | number | string { + if (value === null || value === undefined) { + return ''; + } + + if (isTemporalType) { + // Try parsing as date + const dateValue = new Date(value as string | number); + if (!isNaN(dateValue.getTime())) { + return dateValue; + } + } + + if (typeof value === 'number') { + return value; + } + + return String(value); +} + +/** + * Maps Vega-Lite interpolate to Fluent curve options + * Note: Only maps to curve types supported by LineChartLineOptions + */ +function mapInterpolateToCurve( + interpolate: VegaLiteInterpolate | undefined, +): 'linear' | 'natural' | 'step' | 'stepAfter' | 'stepBefore' | undefined { + if (!interpolate) { + return undefined; + } + + switch (interpolate) { + case 'linear': + case 'linear-closed': + return 'linear'; + case 'step': + return 'step'; + case 'step-before': + return 'stepBefore'; + case 'step-after': + return 'stepAfter'; + case 'natural': + return 'natural'; + case 'monotone': + return 'linear'; + // Note: basis, cardinal, catmull-rom are not supported by LineChartLineOptions + default: + return 'linear'; + } +} + +/** + * Extracts mark properties from VegaLiteMarkDef + */ +function getMarkProperties(mark: VegaLiteMarkDef): { + color?: string; + interpolate?: VegaLiteInterpolate; + strokeWidth?: number; + strokeDash?: number[]; + point?: boolean | { filled?: boolean; size?: number }; +} { + if (typeof mark === 'string') { + return {}; + } + return { + color: mark.color, + interpolate: mark.interpolate, + strokeWidth: mark.strokeWidth, + strokeDash: mark.strokeDash, + point: mark.point, + }; +} + +/** + * Extracts annotations from Vega-Lite layers with text or rule marks + * Text marks become text annotations, rule marks become reference lines + */ +function extractAnnotations(spec: VegaLiteSpec): ChartAnnotation[] { + const annotations: ChartAnnotation[] = []; + + if (!spec.layer || !Array.isArray(spec.layer)) { + return annotations; + } + + spec.layer.forEach((layer, index) => { + const mark = getMarkType(layer.mark); + const encoding = layer.encoding || {}; + + // Text marks become annotations + if (mark === 'text' && encoding.x && encoding.y) { + const textValue = encoding.text?.datum || encoding.text?.value || encoding.text?.field || ''; + const xValue = encoding.x.datum || encoding.x.value || encoding.x.field; + const yValue = encoding.y.datum || encoding.y.value || encoding.y.field; + + if ( + textValue && + (xValue !== undefined || encoding.x.datum !== undefined) && + (yValue !== undefined || encoding.y.datum !== undefined) + ) { + annotations.push({ + id: `text-annotation-${index}`, + text: String(textValue), + coordinates: { + type: 'data', + x: encoding.x.datum || xValue || 0, + y: encoding.y.datum || yValue || 0, + }, + style: { + textColor: typeof layer.mark === 'object' ? layer.mark.color : undefined, + }, + }); + } + } + + // Rule marks can become reference lines (horizontal or vertical) + if (mark === 'rule') { + const markColor = typeof layer.mark === 'object' ? layer.mark.color : '#000'; + const markStrokeWidth = typeof layer.mark === 'object' ? layer.mark.strokeWidth || 1 : 1; + const markStrokeDash = typeof layer.mark === 'object' ? (layer.mark as Record).strokeDash : undefined; + + // Horizontal rule (y value constant) + if (encoding.y && (encoding.y.value !== undefined || encoding.y.datum !== undefined)) { + const yValue = encoding.y.value ?? encoding.y.datum; + // Look for a companion text annotation at the same y-value + const companionText = spec.layer?.find((l, i) => { + if (i === index) { return false; } + const m = getMarkType(l.mark); + return m === 'text' && l.encoding?.y && + ((l.encoding.y.datum ?? l.encoding.y.value) === yValue); + }); + const ruleText = companionText + ? String(companionText.encoding?.text?.datum || companionText.encoding?.text?.value || yValue) + : String(yValue); + + annotations.push({ + id: `rule-h-${index}`, + text: ruleText, + coordinates: { + type: 'data', + x: 0, + y: yValue as number, + }, + style: { + textColor: markColor, + borderColor: markColor, + borderWidth: markStrokeWidth, + ...(markStrokeDash && Array.isArray(markStrokeDash) && { + borderRadius: 0, // Indicate dashed style + }), + }, + }); + } + // Vertical rule (x value constant) + else if (encoding.x && (encoding.x.value !== undefined || encoding.x.datum !== undefined)) { + const xValue = encoding.x.value ?? encoding.x.datum; + annotations.push({ + id: `rule-v-${index}`, + text: String(xValue), + coordinates: { + type: 'data', + x: xValue as number | string | Date, + y: 0, + }, + style: { + textColor: markColor, + borderColor: markColor, + borderWidth: markStrokeWidth, + }, + }); + } + } + }); + + return annotations; +} + +/** + * Extracts color fill bars (background regions) from rect marks with x/x2 or y/y2 encodings + */ +function extractColorFillBars( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): ColorFillBarsProps[] { + const colorFillBars: ColorFillBarsProps[] = []; + + if (!spec.layer || !Array.isArray(spec.layer)) { + return colorFillBars; + } + + // Detect if x-axis is temporal by checking the primary data layer (non-rect layer) + const isXTemporal = spec.layer.some(layer => { + const layerMark = getMarkType(layer.mark); + // Skip rect layers, look at line/point/area layers + if (layerMark === 'rect') { + return false; + } + return layer.encoding?.x?.type === 'temporal'; + }); + + spec.layer.forEach((layer, index) => { + const mark = getMarkType(layer.mark); + const encoding = layer.encoding || {}; + + // Rect marks with x and x2 become color fill bars (vertical regions) + if (mark === 'rect' && encoding.x && encoding.x2) { + const legend = `region-${index}`; + const color = + typeof layer.mark === 'object' && layer.mark.color + ? layer.mark.color + : getVegaColorFromMap(legend, colorMap, undefined, undefined, isDarkTheme); + + // Extract start and end x values + const rawStartX = encoding.x.datum || encoding.x.value; + const rawEndX = encoding.x2.datum || encoding.x2.value; + + if (rawStartX !== undefined && rawEndX !== undefined) { + // Convert to Date if x-axis is temporal and values are date-like strings + let startX: number | Date = rawStartX as number | Date; + let endX: number | Date = rawEndX as number | Date; + + if (isXTemporal) { + const parsedStart = new Date(rawStartX as string | number); + const parsedEnd = new Date(rawEndX as string | number); + if (!isNaN(parsedStart.getTime())) { + startX = parsedStart; + } + if (!isNaN(parsedEnd.getTime())) { + endX = parsedEnd; + } + } + + colorFillBars.push({ + legend, + color, + data: [{ startX, endX }], + applyPattern: false, + }); + } + } + }); + + return colorFillBars; +} + +/** + * Extracts tick configuration from axis properties + */ +function extractTickConfig(spec: VegaLiteSpec): { + tickValues?: (number | Date | string)[]; + xAxisTickCount?: number; + yAxisTickCount?: number; +} { + const config: { + tickValues?: (number | Date | string)[]; + xAxisTickCount?: number; + yAxisTickCount?: number; + } = {}; + + const encoding = spec.encoding || {}; + + if (encoding.x?.axis) { + if (encoding.x.axis.values) { + config.tickValues = encoding.x.axis.values as (number | string)[]; + } + if (encoding.x.axis.tickCount) { + config.xAxisTickCount = encoding.x.axis.tickCount; + } + } + + if (encoding.y?.axis) { + if (encoding.y.axis.tickCount) { + config.yAxisTickCount = encoding.y.axis.tickCount; + } + } + + return config; +} + +/** + * Validates that data array is not empty and contains valid values for the specified field + * @param data - Array of data objects + * @param field - Field name to validate + * @param chartType - Chart type for error message context + * @throws Error if data is empty or field has no valid values + */ +function validateDataArray(data: Array>, field: string, chartType: string): void { + if (!data || data.length === 0) { + throw new Error(`VegaLiteSchemaAdapter: Empty data array for ${chartType}`); + } + + const hasValidValues = data.some(row => row[field] !== undefined && row[field] !== null); + if (!hasValidValues) { + throw new Error(`VegaLiteSchemaAdapter: No valid values found for field '${field}' in ${chartType}`); + } +} + +/** + * Validates that nested arrays are not present in the data field (unsupported) + * @param data - Array of data objects + * @param field - Field name to validate + * @throws Error if nested arrays are detected + */ +function validateNoNestedArrays(data: Array>, field: string): void { + const hasNestedArrays = data.some(row => Array.isArray(row[field])); + if (hasNestedArrays) { + throw new Error( + `VegaLiteSchemaAdapter: Nested arrays not supported for field '${field}'. ` + `Use flat data structures only.`, + ); + } +} + +/** + * Validates data type compatibility with encoding type + * @param data - Array of data objects + * @param field - Field name to validate + * @param expectedType - Expected Vega-Lite type (quantitative, temporal, nominal, ordinal) + * @throws Error if data type doesn't match encoding type + */ +/** + * Validates and potentially auto-corrects encoding types based on actual data + * Returns the corrected type if auto-correction was applied + * + * @param data - Array of data values + * @param field - Field name to validate + * @param expectedType - Expected Vega-Lite type from schema + * @param encoding - Encoding object to potentially modify + * @param channelName - Name of encoding channel (x, y, etc.) for auto-correction + * @returns Corrected type if auto-correction was applied, otherwise undefined + */ +function validateEncodingType( + data: Array>, + field: string, + expectedType: VegaLiteType | undefined, + encoding?: VegaLiteEncoding, + channelName?: 'x' | 'y', +): VegaLiteType | undefined { + if (!expectedType || expectedType === 'nominal' || expectedType === 'ordinal' || expectedType === 'geojson') { + return; // Nominal, ordinal, and geojson accept any type + } + + // Find first non-null value to check type + const sampleValue = data.map(row => row[field]).find(v => v !== null && v !== undefined); + + if (!sampleValue) { + return; // No values to validate + } + + if (expectedType === 'quantitative') { + if (typeof sampleValue !== 'number' && !isFinite(Number(sampleValue))) { + // Type mismatch: quantitative declared but data is not numeric + const actualType = typeof sampleValue; + + if (actualType === 'string') { + // Auto-correct: treat as nominal for categorical string data + // This matches Plotly behavior - render as categorical chart + + // Modify encoding to use nominal type + if (encoding && channelName && encoding[channelName]) { + encoding[channelName]!.type = 'nominal'; + } + + return 'nominal'; + } + + // For non-string types, still throw error (truly invalid) + throw new Error( + `VegaLiteSchemaAdapter: Field '${field}' marked as quantitative but contains non-numeric values (${actualType}).`, + ); + } + } else if (expectedType === 'temporal') { + const isValidDate = + sampleValue instanceof Date || (typeof sampleValue === 'string' && !isNaN(Date.parse(sampleValue))); + if (!isValidDate) { + let suggestion = ''; + if (typeof sampleValue === 'number') { + suggestion = ' The data contains numbers. Change the type to "quantitative" instead.'; + } else if (typeof sampleValue === 'string') { + suggestion = ` The data contains strings that are not valid dates (e.g., "${sampleValue}"). Ensure dates are in ISO format (YYYY-MM-DD) or valid date strings.`; + } + + throw new Error( + `VegaLiteSchemaAdapter: Field '${field}' marked as temporal but contains invalid date values.${suggestion}`, + ); + } + } + + return undefined; +} + +/** + * Validates X and Y encodings for charts requiring both axes + * Performs comprehensive validation including data array, nested arrays, and encoding types + * Can auto-correct type mismatches (e.g., quantitative with string data → nominal) + * + * @param data - Array of data objects + * @param xField - X field name + * @param yField - Y field name + * @param xType - Expected X encoding type + * @param yType - Expected Y encoding type + * @param chartType - Chart type for error message context + * @param encoding - Encoding object (optional, for auto-correction) + * @throws Error if validation fails + */ +function validateXYEncodings( + data: Array>, + xField: string, + yField: string, + xType: VegaLiteType | undefined, + yType: VegaLiteType | undefined, + chartType: string, + encoding?: VegaLiteEncoding, +): void { + validateDataArray(data, xField, chartType); + validateDataArray(data, yField, chartType); + validateNoNestedArrays(data, xField); + validateNoNestedArrays(data, yField); + + // Validate types with auto-correction support + validateEncodingType(data, xField, xType, encoding, 'x'); + validateEncodingType(data, yField, yType, encoding, 'y'); +} + +/** + * Extracts Y-axis scale type from encoding + * Returns 'log' if logarithmic scale is specified, undefined otherwise + */ +function extractYAxisType(encoding: VegaLiteEncoding): 'log' | undefined { + const yScale = encoding?.y?.scale; + return yScale?.type === 'log' ? 'log' : undefined; +} + +/** + * Creates a value formatter from a d3-format specifier string. + * Returns undefined if no format is specified or if the format is invalid. + */ +function createValueFormatter(formatSpec: string | undefined): ((value: number) => string) | undefined { + if (!formatSpec) { + return undefined; + } + try { + const formatter = d3Format(formatSpec); + return formatter; + } catch { + return undefined; + } +} + +/** + * Converts Vega-Lite sort specification to Fluent Charts AxisCategoryOrder + * Supports: 'ascending', 'descending', null, array, or object with op/order + * @param sort - Vega-Lite sort specification + * @returns AxisCategoryOrder compatible value + */ +function convertVegaSortToAxisCategoryOrder(sort: VegaLiteSort): AxisCategoryOrder | undefined { + if (!sort) { + return undefined; + } + + // Handle string sorts: 'ascending' | 'descending' + if (typeof sort === 'string') { + if (sort === 'ascending') { + return 'category ascending'; + } + if (sort === 'descending') { + return 'category descending'; + } + return undefined; + } + + // Handle array sort (explicit ordering) + if (Array.isArray(sort)) { + return sort as string[]; + } + + // Handle object sort with op and order + if (typeof sort === 'object' && sort.op && sort.order) { + const op = sort.op === 'average' ? 'mean' : sort.op; // Map 'average' to 'mean' + const order = sort.order === 'ascending' ? 'ascending' : 'descending'; + return `${op} ${order}` as AxisCategoryOrder; + } + + return undefined; +} + +/** + * Extracts axis category ordering from Vega-Lite encoding + * Returns props for xAxisCategoryOrder and yAxisCategoryOrder + */ +function extractAxisCategoryOrderProps(encoding: VegaLiteEncoding): { + xAxisCategoryOrder?: AxisCategoryOrder; + yAxisCategoryOrder?: AxisCategoryOrder; +} { + const result: { + xAxisCategoryOrder?: AxisCategoryOrder; + yAxisCategoryOrder?: AxisCategoryOrder; + } = {}; + + if (encoding?.x?.sort) { + const xOrder = convertVegaSortToAxisCategoryOrder(encoding.x.sort); + if (xOrder) { + result.xAxisCategoryOrder = xOrder; + } + } + + if (encoding?.y?.sort) { + const yOrder = convertVegaSortToAxisCategoryOrder(encoding.y.sort); + if (yOrder) { + result.yAxisCategoryOrder = yOrder; + } + } + + return result; +} + +/** + * Initializes the transformation context by normalizing spec and extracting common data + * This reduces boilerplate across all transformer functions + * + * @param spec - Vega-Lite specification + * @returns Normalized context with unit specs, data values, encoding, and mark properties + */ +function initializeTransformContext(spec: VegaLiteSpec) { + const unitSpecs = normalizeSpec(spec); + + if (unitSpecs.length === 0) { + throw new Error('VegaLiteSchemaAdapter: No valid unit specs found in specification'); + } + + const primarySpec = unitSpecs[0]; + const rawDataValues = extractDataValues(primarySpec.data); + // Apply any transforms from both top-level spec and primary unit spec + let dataValues = applyTransforms(rawDataValues, spec.transform); + dataValues = applyTransforms(dataValues, primarySpec.transform); + const encoding = primarySpec.encoding || {}; + const markProps = getMarkProperties(primarySpec.mark); + + return { + unitSpecs, + primarySpec, + dataValues, + encoding, + markProps, + }; +} + +/** + * Extracts common encoding fields and aggregates from Vega-Lite encoding + * + * @param encoding - Vega-Lite encoding specification + * @returns Object containing extracted field names and aggregates + */ +function extractEncodingFields(encoding: VegaLiteEncoding) { + return { + xField: encoding.x?.field, + yField: encoding.y?.field, + x2Field: encoding.x2?.field, + colorField: encoding.color?.field, + sizeField: encoding.size?.field, + thetaField: encoding.theta?.field, + radiusField: encoding.radius?.field, + xAggregate: encoding.x?.aggregate, + yAggregate: encoding.y?.aggregate, + }; +} + +/** + * Computes aggregate values for bar charts + * Supports count, sum, mean, min, max aggregations + * + * @param data - Array of data values + * @param groupField - Field to group by (x-axis field) + * @param valueField - Field to aggregate (y-axis field, optional for count) + * @param aggregate - Aggregate function (count, sum, mean, min, max) + * @returns Array of {category, value} objects + */ +function computeAggregateData( + data: Array>, + groupField: string, + valueField: string | undefined, + aggregate: string, +): Array<{ category: string; value: number }> { + // Group data by category + const groups = new Map(); + + data.forEach(row => { + const category = String(row[groupField]); + + if (aggregate === 'count') { + // For count, just track the count + if (!groups.has(category)) { + groups.set(category, []); + } + groups.get(category)!.push(1); + } else if (valueField && row[valueField] !== undefined && row[valueField] !== null) { + // For other aggregates, collect values + const value = Number(row[valueField]); + if (!isNaN(value)) { + if (!groups.has(category)) { + groups.set(category, []); + } + groups.get(category)!.push(value); + } + } + }); + + // Compute aggregate for each group + const result: Array<{ category: string; value: number }> = []; + + groups.forEach((values, category) => { + let aggregatedValue: number; + + switch (aggregate) { + case 'count': + aggregatedValue = values.length; + break; + case 'sum': + aggregatedValue = values.reduce((a, b) => a + b, 0); + break; + case 'mean': + case 'average': + aggregatedValue = values.reduce((a, b) => a + b, 0) / values.length; + break; + case 'min': + aggregatedValue = d3Min(values) ?? 0; + break; + case 'max': + aggregatedValue = d3Max(values) ?? 0; + break; + default: + aggregatedValue = values.length; // Default to count + } + + result.push({ category, value: aggregatedValue }); + }); + + return result; +} + +/** + * Counts rows per x-category, optionally grouped by a secondary (color) field. + * Returns a Map>. + */ +function countByCategory( + dataValues: Array>, + xField: string, + colorField: string | undefined, + defaultLegend: string, +): Map> { + const countMap = new Map>(); + dataValues.forEach(row => { + const xValue = row[xField]; + if (xValue === undefined) { + return; + } + const xKey = String(xValue); + const legend = colorField && row[colorField] !== undefined ? String(row[colorField]) : defaultLegend; + if (!countMap.has(xKey)) { + countMap.set(xKey, new Map()); + } + const legendMap = countMap.get(xKey)!; + legendMap.set(legend, (legendMap.get(legend) || 0) + 1); + }); + return countMap; +} + +/** + * Extracts color configuration from Vega-Lite encoding + * + * @param encoding - Vega-Lite encoding specification + * @returns Color scheme and range configuration + */ +function extractColorConfig(encoding: VegaLiteEncoding) { + return { + colorScheme: encoding.color?.scale?.scheme, + colorRange: encoding.color?.scale?.range as string[] | undefined, + }; +} + +/** + * Groups data rows into series based on color encoding field + * Returns a map of series name to data points and ordinal mapping for categorical x-axis + */ +function groupDataBySeries( + dataValues: Array>, + xField: string | undefined, + yField: string | undefined, + colorField: string | undefined, + isXTemporal: boolean, + isYTemporal: boolean, + xType?: VegaLiteType, + sizeField?: string | undefined, +): { seriesMap: Map; ordinalMapping?: Map; ordinalLabels?: string[] } { + const seriesMap = new Map(); + + if (!xField || !yField) { + return { seriesMap }; + } + + // Check if x-axis is ordinal/nominal (categorical) + const isXOrdinal = xType === 'ordinal' || xType === 'nominal'; + const ordinalMapping = isXOrdinal ? new Map() : undefined; + const ordinalLabels: string[] = []; + + dataValues.forEach(row => { + const xValue = parseValue(row[xField], isXTemporal); + const yValue = parseValue(row[yField], isYTemporal); + + // Skip invalid values using chart-utilities validation + if (isInvalidValue(xValue) || isInvalidValue(yValue)) { + return; + } + + // Skip if x or y is empty string (from null/undefined) or y is not a valid number/string + if (xValue === '' || yValue === '' || (typeof yValue !== 'number' && typeof yValue !== 'string')) { + return; + } + + const seriesName = colorField && row[colorField] !== undefined ? String(row[colorField]) : 'default'; + + if (!seriesMap.has(seriesName)) { + seriesMap.set(seriesName, []); + } + + // Handle x-value based on type + let numericX: number | Date; + if (isXOrdinal && typeof xValue === 'string') { + // For ordinal data, map each unique string to a sequential index + if (!ordinalMapping!.has(xValue)) { + ordinalMapping!.set(xValue, ordinalMapping!.size); + ordinalLabels.push(xValue); + } + numericX = ordinalMapping!.get(xValue)!; + } else if (typeof xValue === 'string') { + // For non-ordinal strings, try to parse as float (fallback to 0) + const parsed = parseFloat(xValue); + if (isNaN(parsed)) { + return; + } + numericX = parsed; + } else { + numericX = xValue; + } + + const markerSize = sizeField && row[sizeField] !== undefined ? Number(row[sizeField]) : undefined; + + seriesMap.get(seriesName)!.push({ + x: numericX, + y: yValue as number, + ...(markerSize !== undefined && !isNaN(markerSize) && { markerSize }), + }); + }); + + return { seriesMap, ordinalMapping, ordinalLabels: ordinalLabels.length > 0 ? ordinalLabels : undefined }; +} + +/** + * Finds the primary data layer from unit specs for line/area charts + * Skips rect layers (used for color fill bars) and finds the actual line/point/area layer + * + * @param unitSpecs - Array of normalized unit specs + * @returns The primary spec containing the actual chart data, or undefined if not found + */ +function findPrimaryLineSpec(unitSpecs: VegaLiteUnitSpec[]): VegaLiteUnitSpec | undefined { + // First, try to find a line, point, or area layer + const lineSpec = unitSpecs.find(spec => { + const markType = getMarkType(spec.mark); + return markType === 'line' || markType === 'point' || markType === 'area'; + }); + + if (lineSpec) { + return lineSpec; + } + + // If no line/point/area layer, find first layer with actual field encodings (not just datum) + const dataSpec = unitSpecs.find(spec => { + const encoding = spec.encoding || {}; + return encoding.x?.field || encoding.y?.field; + }); + + return dataSpec || unitSpecs[0]; +} + +/** + * Transforms Vega-Lite specification to Fluent LineChart props + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns LineChartProps for rendering with Fluent LineChart component + */ + +/** + * Auto-corrects encoding types in a Vega-Lite spec based on actual data values. + * Call this before chart type detection so routing decisions use corrected types. + * + * Corrections applied: + * - quantitative + string data → nominal (render as categorical) + * + * This mutates the spec encoding in place. + */ +export function autoCorrectEncodingTypes(spec: VegaLiteSpec): void { + const unitSpec = spec.layer ? spec.layer[0] : spec; + if (!unitSpec) { + return; + } + + const encoding = unitSpec.encoding; + const data = extractDataValues(unitSpec.data ?? spec.data); + + if (!encoding || data.length === 0) { + return; + } + + // Check x encoding + if (encoding.x?.field) { + const sample = data.map(row => row[encoding.x!.field!]).find(v => v !== null && v !== undefined); + if (sample !== undefined) { + if (encoding.x.type === 'quantitative') { + if (typeof sample === 'string' && !isFinite(Number(sample))) { + encoding.x.type = 'nominal'; + } else if (typeof sample === 'object') { + encoding.x.type = 'nominal'; + } + } else if (encoding.x.type === 'temporal') { + const isValidDate = sample instanceof Date || (typeof sample === 'string' && !isNaN(Date.parse(sample))); + if (!isValidDate) { + encoding.x.type = typeof sample === 'number' ? 'quantitative' : 'nominal'; + } + } + } + } + + // Check y encoding + if (encoding.y?.field) { + const sample = data.map(row => row[encoding.y!.field!]).find(v => v !== null && v !== undefined); + if (sample !== undefined) { + if (encoding.y.type === 'quantitative') { + if (typeof sample === 'string' && !isFinite(Number(sample))) { + encoding.y.type = 'nominal'; + } else if (typeof sample === 'object' && sample !== null && !Array.isArray(sample) && !(sample instanceof Date)) { + // Try to extract a numeric value from the object + const numericKeys = Object.keys(sample as Record).filter( + k => typeof (sample as Record)[k] === 'number', + ); + if (numericKeys.length === 1) { + // Object has exactly one numeric property - use it as the value + const numericKey = numericKeys[0]; + const yField = encoding.y.field!; + data.forEach(row => { + const obj = row[yField]; + if (typeof obj === 'object' && obj !== null) { + row[yField] = (obj as Record)[numericKey]; + } + }); + // Keep type as quantitative since we extracted numeric values + } else { + encoding.y.type = 'nominal'; + } + } else if (typeof sample === 'object') { + encoding.y.type = 'nominal'; + } + } else if (encoding.y.type === 'temporal') { + const isValidDate = sample instanceof Date || (typeof sample === 'string' && !isNaN(Date.parse(sample))); + if (!isValidDate) { + encoding.y.type = typeof sample === 'number' ? 'quantitative' : 'nominal'; + } + } + } + } +} + +export type ChartTypeResult = { + type: 'line' | 'bar' | 'stacked-bar' | 'grouped-bar' | 'horizontal-bar' | 'area' | 'scatter' | 'donut' | 'heatmap' | 'histogram' | 'polar'; + mark: string; +}; + +/** + * Determines the chart type based on Vega-Lite spec + */ +export function getChartType(spec: VegaLiteSpec): ChartTypeResult { + // Auto-correct encoding types based on actual data BEFORE chart type detection + autoCorrectEncodingTypes(spec); + + // Handle layered specs - check if it's a bar+line combo for stacked bar with lines + if (spec.layer && spec.layer.length > 1) { + const marks = spec.layer.map((layer: VegaLiteUnitSpec) => getMarkType(layer.mark)); + const hasBar = marks.includes('bar'); + const hasLine = marks.includes('line') || marks.includes('point'); + + // Bar + line combo should use stacked bar chart (which supports line overlays) + if (hasBar && hasLine) { + const barLayer = spec.layer.find((layer: VegaLiteUnitSpec) => getMarkType(layer.mark) === 'bar'); + + if (barLayer?.encoding?.color?.field) { + return { type: 'stacked-bar', mark: 'bar' }; + } + return { type: 'stacked-bar', mark: 'bar' }; + } + } + + // Handle layered specs - use first layer's mark for other cases + const mark = spec.layer ? spec.layer[0]?.mark : spec.mark; + const markType = getMarkType(mark); + + const encoding = spec.layer ? spec.layer[0]?.encoding : spec.encoding; + const hasColorEncoding = !!encoding?.color?.field; + + // Polar charts with arc marks: theta AND radius encodings + if (markType === 'arc' && encoding?.theta && encoding?.radius) { + return { type: 'polar', mark: markType }; + } + + // Arc marks for pie/donut charts (theta only, no radius) + if (markType === 'arc' && encoding?.theta) { + return { type: 'donut', mark: markType }; + } + + // Polar charts: non-arc marks with theta and radius encodings + if (encoding?.theta && encoding?.radius) { + return { type: 'polar', mark: markType! }; + } + + // Rect marks for heatmaps (quantitative or nominal color) + if ( + markType === 'rect' && + encoding?.x?.field && + encoding?.y?.field && + encoding?.color?.field + ) { + return { type: 'heatmap', mark: markType }; + } + + // Bar charts + if (markType === 'bar') { + if (encoding?.x?.bin) { + return { type: 'histogram', mark: markType }; + } + + const isXNominal = encoding?.x?.type === 'nominal' || encoding?.x?.type === 'ordinal'; + const isYNominal = encoding?.y?.type === 'nominal' || encoding?.y?.type === 'ordinal'; + + if (isYNominal && !isXNominal) { + return { type: 'horizontal-bar', mark: markType }; + } + + if (hasColorEncoding) { + const hasXOffset = !!(encoding as Record)?.xOffset; + if (hasXOffset) { + return { type: 'grouped-bar', mark: markType }; + } + return { type: 'stacked-bar', mark: markType }; + } + + const xField = encoding?.x?.field; + const dataValues = spec.data?.values; + if (xField && Array.isArray(dataValues) && dataValues.length > 0) { + const xValues = dataValues.map((row: Record) => row[xField]); + const uniqueXValues = new Set(xValues); + if (uniqueXValues.size < xValues.length) { + return { type: 'stacked-bar', mark: markType }; + } + } + + return { type: 'bar', mark: markType }; + } + + if (markType === 'area') { + return { type: 'area', mark: markType }; + } + + if (markType === 'point' || markType === 'circle' || markType === 'square') { + return { type: 'scatter', mark: markType }; + } + + return { type: 'line', mark: markType || 'line' }; +} + +export function transformVegaLiteToLineChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): LineChartProps { + // Initialize transformation context, but find the primary line/point layer for layered specs + const unitSpecs = normalizeSpec(spec); + + if (unitSpecs.length === 0) { + throw new Error('VegaLiteSchemaAdapter: No valid unit specs found in specification'); + } + + // For layered specs, find the actual line/point layer (not rect layers for color fill bars) + const primarySpec = findPrimaryLineSpec(unitSpecs); + if (!primarySpec) { + throw new Error('VegaLiteSchemaAdapter: No valid line/point layer found in specification'); + } + + // Check if there's a point layer in addition to line layer (for line+point combo charts) + const hasPointLayer = unitSpecs.some(unitSpec => getMarkType(unitSpec.mark) === 'point'); + const hasLineLayer = unitSpecs.some(unitSpec => getMarkType(unitSpec.mark) === 'line'); + const shouldShowPoints = hasPointLayer && hasLineLayer; + + const rawDataValues = extractDataValues(primarySpec.data); + // Apply any transforms (fold, etc.) from the spec + const dataValues = applyTransforms(rawDataValues, spec.transform); + const encoding = primarySpec.encoding || {}; + const markProps = getMarkProperties(primarySpec.mark); + + // Extract field names + const { xField, yField, colorField } = extractEncodingFields(encoding); + + // Check for size encoding from any layer (e.g., point layer with size in line+point combo) + let sizeField: string | undefined; + if (unitSpecs.length > 1) { + for (const unitSpec of unitSpecs) { + const unitEncoding = unitSpec.encoding || {}; + if (unitEncoding.size?.field) { + sizeField = unitEncoding.size.field; + break; + } + } + } else { + sizeField = encoding.size?.field; + } + + // Validate data and encodings + if (!xField || !yField) { + throw new Error('VegaLiteSchemaAdapter: Line chart requires both x and y encodings with field names'); + } + + validateXYEncodings(dataValues, xField, yField, encoding.x?.type, encoding.y?.type, 'LineChart', encoding); + + const isXTemporal = encoding.x?.type === 'temporal'; + const isYTemporal = encoding.y?.type === 'temporal'; + + // Group data into series + const { seriesMap, ordinalLabels } = groupDataBySeries( + dataValues, + xField, + yField, + colorField, + isXTemporal, + isYTemporal, + encoding.x?.type, + sizeField, + ); + + // Extract color configuration + const { colorScheme, colorRange } = extractColorConfig(encoding); + + // Convert series map to LineChartPoints array + const lineChartData: LineChartPoints[] = []; + const colorIndex = new Map(); + let currentColorIndex = 0; + + seriesMap.forEach((dataPoints, seriesName) => { + if (!colorIndex.has(seriesName)) { colorIndex.set(seriesName, currentColorIndex++); } + const color = resolveColor(seriesName, colorIndex.get(seriesName)!, undefined, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + + const curveOption = mapInterpolateToCurve(markProps.interpolate); + + // Build line options with curve, strokeDash, and strokeWidth + const lineOptions: Partial = {}; + if (curveOption) { + lineOptions.curve = curveOption; + } + if (markProps.strokeDash) { + lineOptions.strokeDasharray = markProps.strokeDash.join(' '); + } + if (markProps.strokeWidth) { + lineOptions.strokeWidth = markProps.strokeWidth; + } + + lineChartData.push({ + legend: seriesName, + data: dataPoints, + color, + hideNonActiveDots: !shouldShowPoints, + ...(Object.keys(lineOptions).length > 0 && { lineOptions }), + }); + }); + + // Extract chart title + const chartTitle = typeof spec.title === 'string' ? spec.title : spec.title?.text; + + // Extract axis titles and formats + const xAxisTitle = encoding.x?.axis?.title ?? undefined; + const yAxisTitle = encoding.y?.axis?.title ?? undefined; + const tickFormat = encoding.x?.axis?.format; + const yAxisTickFormat = encoding.y?.axis?.format; + + // Extract tick values and counts + // Use ordinalLabels for ordinal x-axis, otherwise use explicit values from spec + const tickValues = ordinalLabels || encoding.x?.axis?.values; + const yAxisTickCount = encoding.y?.axis?.tickCount; + + // Extract domain/range for min/max values + const yMinValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[0] as number) : undefined; + const yMaxValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[1] as number) : undefined; + + // Extract annotations and color fill bars from layers + const annotations = extractAnnotations(spec); + const colorFillBars = extractColorFillBars(spec, colorMap, isDarkTheme); + + // Convert rule marks in layered specs to reference line series + // Each horizontal rule becomes a 2-point line at constant y spanning the data x-range + if (spec.layer && Array.isArray(spec.layer) && lineChartData.length > 0) { + const allXValues = lineChartData.flatMap(series => series.data.map(p => p.x)); + const xMin = allXValues.length > 0 ? allXValues.reduce((a, b) => (a < b ? a : b)) : 0; + const xMax = allXValues.length > 0 ? allXValues.reduce((a, b) => (a > b ? a : b)) : 0; + + spec.layer.forEach((layer, layerIndex) => { + const layerMark = getMarkType(layer.mark); + if (layerMark !== 'rule') { + return; + } + + const ruleEncoding = layer.encoding || {}; + const yDatum = ruleEncoding.y?.datum ?? ruleEncoding.y?.value; + if (yDatum === undefined) { + return; + } + + const ruleMarkProps = getMarkProperties(layer.mark); + const ruleColor = ruleMarkProps.color || '#d62728'; + + // Find companion text annotation for legend name + const textLayer = spec.layer!.find(l => { + const m = getMarkType(l.mark); + return m === 'text' && l.encoding?.y && + ((l.encoding.y.datum ?? l.encoding.y.value) === yDatum); + }); + const ruleLegend = textLayer + ? String(textLayer.encoding?.text?.datum || textLayer.encoding?.text?.value || `y=${yDatum}`) + : `y=${yDatum}`; + + const ruleLineOptions: Partial = {}; + if (ruleMarkProps.strokeDash) { + ruleLineOptions.strokeDasharray = ruleMarkProps.strokeDash.join(' '); + } + if (ruleMarkProps.strokeWidth) { + ruleLineOptions.strokeWidth = ruleMarkProps.strokeWidth; + } + + lineChartData.push({ + legend: ruleLegend, + data: [ + { x: xMin as number | Date, y: yDatum as number }, + { x: xMax as number | Date, y: yDatum as number }, + ], + color: ruleColor, + hideNonActiveDots: true, + ...(Object.keys(ruleLineOptions).length > 0 && { lineOptions: ruleLineOptions }), + }); + }); + } + + // Check for log scale on Y-axis + const yAxisType = extractYAxisType(encoding); + + // Extract axis category ordering + const categoryOrderProps = extractAxisCategoryOrderProps(encoding); + + // Build LineChartProps + const chartProps: ChartProps = { + lineChartData, + ...(chartTitle && { chartTitle }), + }; + + return { + data: chartProps, + width: typeof spec.width === 'number' ? spec.width : undefined, + height: typeof spec.height === 'number' ? spec.height : undefined, + ...(xAxisTitle && { chartTitle: xAxisTitle }), + ...(yAxisTitle && { yAxisTitle }), + ...(tickFormat && { tickFormat }), + ...(yAxisTickFormat && { yAxisTickFormat }), + ...(tickValues && { tickValues }), + ...(yAxisTickCount && { yAxisTickCount }), + ...(yMinValue !== undefined && { yMinValue }), + ...(yMaxValue !== undefined && { yMaxValue }), + ...(annotations.length > 0 && { annotations }), + ...(colorFillBars.length > 0 && { colorFillBars }), + ...(yAxisType && { yScaleType: yAxisType }), + ...categoryOrderProps, + hideLegend: encoding.color?.legend?.disable ?? false, + }; +} + +/** + * Generates legend props from Vega-Lite specification + * Used for multi-plot scenarios where legends are rendered separately + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns LegendsProps for rendering legends + */ +export function getVegaLiteLegendsProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): LegendsProps { + const unitSpecs = normalizeSpec(spec); + const legends: Legend[] = []; + + if (unitSpecs.length === 0) { + return { + legends, + centerLegends: true, + enabledWrapLines: true, + canSelectMultipleLegends: true, + }; + } + + const primarySpec = unitSpecs[0]; + const dataValues = extractDataValues(primarySpec.data); + const encoding = primarySpec.encoding || {}; + const colorField = encoding.color?.field; + + if (!colorField) { + return { + legends, + centerLegends: true, + enabledWrapLines: true, + canSelectMultipleLegends: true, + }; + } + + // Extract unique series names + const seriesNames = new Set(); + dataValues.forEach(row => { + if (row[colorField] !== undefined) { + seriesNames.add(String(row[colorField])); + } + }); + + // Generate legends + seriesNames.forEach(seriesName => { + const color = getVegaColorFromMap(seriesName, colorMap, undefined, undefined, isDarkTheme); + legends.push({ + title: seriesName, + color, + }); + }); + + return { + legends, + centerLegends: true, + enabledWrapLines: true, + canSelectMultipleLegends: true, + }; +} + +/** + * Extracts chart titles and title styles from Vega-Lite specification + */ +export function getVegaLiteTitles(spec: VegaLiteSpec): { + chartTitle?: string; + xAxisTitle?: string; + yAxisTitle?: string; + titleStyles?: TitleStyles; +} { + const unitSpecs = normalizeSpec(spec); + + if (unitSpecs.length === 0) { + return {}; + } + + const primarySpec = unitSpecs[0]; + const encoding = primarySpec.encoding || {}; + + // Extract chart title + const chartTitle = typeof spec.title === 'string' ? spec.title : spec.title?.text; + + // Extract title styles if title is an object + let titleStyles: TitleStyles | undefined; + if (typeof spec.title === 'object' && spec.title !== null) { + const titleObj = spec.title as VegaLiteTitleParams; + + // Build titleFont object if any font properties are present + const titleFont: TitleStyles['titleFont'] = {}; + if (titleObj.font) { + titleFont.family = titleObj.font; + } + if (titleObj.fontSize) { + titleFont.size = titleObj.fontSize; + } + if (titleObj.fontWeight) { + // Convert string weights to numbers (Font interface expects number) + const weight = titleObj.fontWeight; + if (typeof weight === 'string') { + const weightMap: Record = { + normal: 400, + bold: 700, + lighter: 300, + bolder: 600, + }; + titleFont.weight = weightMap[weight.toLowerCase()] || 400; + } else { + titleFont.weight = weight; + } + } + if (titleObj.color) { + titleFont.color = titleObj.color; + } + + // Map Vega-Lite anchor values to TitleStyles anchor values + const anchorMap: Record = { + start: 'left', + middle: 'center', + end: 'right', + }; + + titleStyles = { + ...(Object.keys(titleFont).length > 0 ? { titleFont } : {}), + ...(titleObj.anchor && anchorMap[titleObj.anchor] ? { titleXAnchor: anchorMap[titleObj.anchor] } : {}), + ...(titleObj.offset !== undefined || titleObj.subtitlePadding !== undefined + ? { + titlePad: { + t: titleObj.offset, + b: titleObj.subtitlePadding, + }, + } + : {}), + }; + + // Only include titleStyles if it has properties + if (Object.keys(titleStyles).length === 0) { + titleStyles = undefined; + } + } + + return { + chartTitle, + xAxisTitle: encoding.x?.title ?? encoding.x?.axis?.title ?? undefined, + yAxisTitle: encoding.y?.title ?? encoding.y?.axis?.title ?? undefined, + ...(titleStyles ? { titleStyles } : {}), + }; +} + +/** + * Transforms Vega-Lite specification to Fluent VerticalBarChart props + * + * Supports bar mark with quantitative y-axis and nominal/ordinal x-axis + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns VerticalBarChartProps for rendering + */ +export function transformVegaLiteToVerticalBarChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): VerticalBarChartProps { + // Initialize transformation context + const { dataValues, encoding, markProps } = initializeTransformContext(spec); + + // Extract field names and aggregates + const { xField, yField, colorField, yAggregate } = extractEncodingFields(encoding); + + // Check if this is an aggregate bar chart + // Aggregate can be: count (no field needed) or sum/mean/etc (with field) + const isAggregate = !!yAggregate; + + if (!xField && !isAggregate) { + throw new Error('VegaLiteSchemaAdapter: x encoding is required for bar charts'); + } + + // For aggregate charts, compute aggregated data + let aggregatedData: Array<{ category: string; value: number }> | undefined; + if (isAggregate && xField) { + aggregatedData = computeAggregateData(dataValues, xField, yField, yAggregate as string); + } + + // Validate data and encodings (skip for aggregate charts) + if (!isAggregate && xField && yField) { + validateXYEncodings(dataValues, xField, yField, encoding.x?.type, encoding.y?.type, 'VerticalBarChart', encoding); + } + + // Extract color configuration + const { colorScheme, colorRange } = extractColorConfig(encoding); + const colorValue = encoding.color?.value as string | undefined; + + const barData: VerticalBarChartDataPoint[] = []; + const colorIndex = new Map(); + let currentColorIndex = 0; + + // When there's no color field, all bars share a single legend + const useSingleLegendForAggregate = !colorField; + + if (aggregatedData) { + // Use aggregated data + aggregatedData.forEach(({ category, value }) => { + const legend = useSingleLegendForAggregate ? 'Bar' : String(category); + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + + const color = resolveColor(legend, colorIndex.get(legend)!, colorValue, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + + barData.push({ + x: category, + y: value, + legend, + color, + }); + }); + } else if (xField && yField) { + // Check if y values are numeric; if not, fall back to count aggregation + const firstYValue = dataValues.find(r => r[yField] !== undefined)?.[yField]; + const yIsNumeric = typeof firstYValue === 'number'; + + if (!yIsNumeric) { + // y values are non-numeric: compute count per x category + const counts = countByCategory(dataValues, xField, undefined, ''); + counts.forEach((legendMap, xKey) => { + // No color grouping - each xKey gets one bar; use xKey as legend + const totalCount = Array.from(legendMap.values()).reduce((a, b) => a + b, 0); + const legend = xKey; + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + const color = resolveColor(legend, colorIndex.get(legend)!, colorValue, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + barData.push({ x: xKey, y: totalCount, legend, color }); + }); + } else { + // When there's no color field encoding, use a single legend name for all bars + // This ensures: uniform bar color, single legend entry, no tooltip duplication + const useSingleLegend = !colorField; + + // Create value formatter for bar data labels + const yFormatter = createValueFormatter(encoding.y?.axis?.format); + + // Use raw data (normal numeric y values) + dataValues.forEach(row => { + const xValue = row[xField]; + const yValue = row[yField]; + + // Use chart-utilities validation + if (isInvalidValue(xValue) || isInvalidValue(yValue) || typeof yValue !== 'number') { + return; + } + + const legend = colorField && row[colorField] !== undefined + ? String(row[colorField]) + : useSingleLegend ? 'Bar' : String(xValue); + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + + const color = resolveColor(legend, colorIndex.get(legend)!, colorValue, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + + // For bar charts, x-axis values are treated as categories (even if numeric) + // Convert to string to ensure consistent categorical positioning + const xCategory = typeof xValue === 'number' ? String(xValue) : (xValue as string); + + barData.push({ + x: xCategory, + y: yValue, + legend, + color, + ...(yFormatter && { yAxisCalloutData: yFormatter(yValue), barLabel: yFormatter(yValue) }), + }); + }); + } + } + + const titles = getVegaLiteTitles(spec); + + // Extract axis category ordering + const categoryOrderProps = extractAxisCategoryOrderProps(encoding); + + // Extract tick configuration + const tickConfig = extractTickConfig(spec); + + // Extract y-axis formatting and scale props + const yAxisTickFormat = encoding.y?.axis?.format; + const yMinValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[0] as number) : undefined; + const yMaxValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[1] as number) : undefined; + const yAxisType = extractYAxisType(encoding); + + const result: VerticalBarChartProps = { + data: barData, + chartTitle: titles.chartTitle, + xAxisTitle: titles.xAxisTitle, + yAxisTitle: titles.yAxisTitle, + ...(titles.titleStyles ? titles.titleStyles : {}), + roundCorners: true, + wrapXAxisLables: typeof barData[0]?.x === 'string', + hideTickOverlap: true, + ...(yAxisTickFormat && { yAxisTickFormat }), + ...(yMinValue !== undefined && { yMinValue }), + ...(yMaxValue !== undefined && { yMaxValue }), + ...(yAxisType && { yScaleType: yAxisType }), + ...categoryOrderProps, + }; + + if (tickConfig.tickValues) { + result.tickValues = tickConfig.tickValues as number[] | Date[]; + } + + if (tickConfig.xAxisTickCount) { + result.xAxisTickCount = tickConfig.xAxisTickCount; + } + + return result; +} + +/** + * Transforms Vega-Lite specification to Fluent VerticalStackedBarChart props + * + * Supports stacked bar charts with color encoding for stacking + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns VerticalStackedBarChartProps for rendering + */ +export function transformVegaLiteToVerticalStackedBarChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): VerticalStackedBarChartProps { + // Initialize transformation context (skip warnings as we handle layered spec differently) + const { unitSpecs } = initializeTransformContext(spec); + + // Separate bar, line, and rule specs from layered specifications + const barSpecs = unitSpecs.filter(s => getMarkType(s.mark) === 'bar'); + + const lineSpecs = unitSpecs.filter(s => { + const mark = getMarkType(s.mark); + return mark === 'line' || mark === 'point'; + }); + + const ruleSpecs = unitSpecs.filter(s => getMarkType(s.mark) === 'rule'); + + // Use bar specs if available, otherwise fall back to first unit spec + const primarySpec = barSpecs.length > 0 ? barSpecs[0] : unitSpecs[0]; + const rawDataValues = extractDataValues(primarySpec.data); + // Apply transforms from both top-level spec and primary spec + let dataValues = applyTransforms(rawDataValues, spec.transform); + dataValues = applyTransforms(dataValues, primarySpec.transform); + const encoding = primarySpec.encoding || {}; + const markProps = getMarkProperties(primarySpec.mark); + + // Extract field names and aggregates + const { xField, yField, colorField, yAggregate } = extractEncodingFields(encoding); + const colorValue = encoding.color?.value; // Static color value + + // Support aggregate encodings (e.g., count, sum) + const isAggregate = !!yAggregate; + + if (!xField) { + throw new Error('VegaLiteSchemaAdapter: x encoding is required for stacked bar charts'); + } + + // For aggregate charts, compute aggregated data + let aggregatedData: Array<{ category: string; value: number }> | undefined; + if (isAggregate) { + aggregatedData = computeAggregateData(dataValues, xField, yField, yAggregate as string); + } else if (!yField) { + throw new Error('VegaLiteSchemaAdapter: y encoding is required for stacked bar charts'); + } + + // Extract color configuration + const { colorScheme, colorRange } = extractColorConfig(encoding); + + // Group data by x value, then by color (stack) + const mapXToDataPoints: { [key: string]: VerticalStackedChartProps } = {}; + const colorIndex = new Map(); + let currentColorIndex = 0; + + if (aggregatedData) { + // Use aggregated data + aggregatedData.forEach(({ category, value }) => { + const xKey = String(category); + const legend = 'Bar'; + + if (!mapXToDataPoints[xKey]) { + mapXToDataPoints[xKey] = { + xAxisPoint: category, + chartData: [], + lineData: [], + }; + } + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + + const color = + resolveColor(legend, colorIndex.get(legend)!, colorValue as string | undefined, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + + mapXToDataPoints[xKey].chartData.push({ + legend, + data: value, + color, + }); + }); + } else { + // Check if y values are actually numeric; if not, fall back to count aggregation + const yType = encoding.y?.type; + const firstYValue = dataValues.find(r => r[yField!] !== undefined)?.[yField!]; + const yIsNumeric = typeof firstYValue === 'number'; + + if (!yIsNumeric && yField) { + // y values are non-numeric (e.g., strings after auto-correction from quantitative to nominal) + // Fall back to count aggregation: count rows per x category and color + const counts = countByCategory(dataValues, xField, colorField, 'Bar'); + counts.forEach((legendMap, xKey) => { + mapXToDataPoints[xKey] = { + xAxisPoint: xKey, + chartData: [], + lineData: [], + }; + legendMap.forEach((count, legend) => { + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + const color = + resolveColor(legend, colorIndex.get(legend)!, colorValue as string | undefined, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + mapXToDataPoints[xKey].chartData.push({ + legend, + data: count, + color, + }); + }); + }); + } else { + // Process bar data (normal numeric y values) + dataValues.forEach(row => { + const xValue = row[xField]; + const yValue = row[yField!]; + const stackValue = colorField ? row[colorField] : 'Bar'; // Default legend if no color field + + if (isInvalidValue(xValue) || isInvalidValue(yValue) || typeof yValue !== 'number') { + return; + } + + const xKey = String(xValue); + const legend = stackValue !== undefined ? String(stackValue) : 'Bar'; + + if (!mapXToDataPoints[xKey]) { + // For bar charts, x-axis values are treated as categories (even if numeric) + const xCategory = typeof xValue === 'number' ? String(xValue) : (xValue as string); + mapXToDataPoints[xKey] = { + xAxisPoint: xCategory, + chartData: [], + lineData: [], + }; + } + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + + // Use static color if provided, otherwise use color scheme/scale + const color = + resolveColor(legend, colorIndex.get(legend)!, colorValue as string | undefined, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + + const stackYFormatter = createValueFormatter(encoding.y?.axis?.format); + mapXToDataPoints[xKey].chartData.push({ + legend, + data: yValue, + color, + ...(stackYFormatter && { yAxisCalloutData: stackYFormatter(yValue), barLabel: stackYFormatter(yValue) }), + }); + }); + } + } // end else (non-aggregate) + + // Process line data from additional layers (if any) + lineSpecs.forEach((lineSpec, lineIndex) => { + let lineDataValues = extractDataValues(lineSpec.data); + // Apply transforms from both top-level spec and line spec + lineDataValues = applyTransforms(lineDataValues, spec.transform); + lineDataValues = applyTransforms(lineDataValues, lineSpec.transform); + const lineEncoding = lineSpec.encoding || {}; + const lineMarkProps = getMarkProperties(lineSpec.mark); + + const lineXField = lineEncoding.x?.field; + const lineYField = lineEncoding.y?.field; + const lineColorField = lineEncoding.color?.field; + + if (!lineXField || !lineYField) { + return; // Skip if required fields are missing + } + + const lineLegendBase = lineColorField ? 'Line' : `Line ${lineIndex + 1}`; + + lineDataValues.forEach(row => { + const xValue = row[lineXField]; + const yValue = row[lineYField]; + + if (isInvalidValue(xValue) || isInvalidValue(yValue)) { + return; + } + + const xKey = String(xValue); + const lineLegend = + lineColorField && row[lineColorField] !== undefined ? String(row[lineColorField]) : lineLegendBase; + + // Ensure x-axis point exists + if (!mapXToDataPoints[xKey]) { + mapXToDataPoints[xKey] = { + xAxisPoint: xValue as number | string, + chartData: [], + lineData: [], + }; + } + + // Determine line color + if (!colorIndex.has(lineLegend)) { + colorIndex.set(lineLegend, currentColorIndex++); + } + let lineColor: string; + if (lineMarkProps.color) { + lineColor = lineMarkProps.color; + } else { + // Use lineLegend for consistent color assignment + lineColor = resolveColor(lineLegend, colorIndex.get(lineLegend)!, undefined, undefined, colorMap, undefined, undefined, isDarkTheme); + } + + // Determine if this line should use secondary Y-axis + // Check if spec has independent Y scales AND line uses different Y field than bars + const hasIndependentYScales = spec.resolve?.scale?.y === 'independent'; + const useSecondaryYScale = hasIndependentYScales && lineYField !== yField; + + const lineData: LineDataInVerticalStackedBarChart = { + y: yValue as number, + color: lineColor, + legend: lineLegend, + legendShape: 'triangle', + data: typeof yValue === 'number' ? yValue : undefined, + useSecondaryYScale, + }; + + // Add line options if available + if (lineMarkProps.strokeWidth || lineMarkProps.strokeDash) { + lineData.lineOptions = { + ...(lineMarkProps.strokeWidth && { strokeWidth: lineMarkProps.strokeWidth }), + ...(lineMarkProps.strokeDash && { strokeDasharray: lineMarkProps.strokeDash.join(' ') }), + }; + } + + mapXToDataPoints[xKey].lineData!.push(lineData); + }); + }); + + // Process rule specs as horizontal reference lines + // Each rule with a constant y-value becomes a flat line across all x-axis points + ruleSpecs.forEach((ruleSpec, ruleIndex) => { + const ruleEncoding = ruleSpec.encoding || {}; + const ruleMarkProps = getMarkProperties(ruleSpec.mark); + const yDatum = ruleEncoding.y?.datum ?? ruleEncoding.y?.value; + + if (yDatum !== undefined) { + const ruleLegend = `Reference_${ruleIndex}`; + const ruleColor = ruleMarkProps.color || '#d62728'; + + if (!colorIndex.has(ruleLegend)) { + colorIndex.set(ruleLegend, currentColorIndex++); + } + + const lineOptions: Partial = {}; + if (ruleMarkProps.strokeDash) { + lineOptions.strokeDasharray = ruleMarkProps.strokeDash.join(' '); + } + if (ruleMarkProps.strokeWidth) { + lineOptions.strokeWidth = ruleMarkProps.strokeWidth; + } + + // Look for companion text annotation at the same y-value + const textSpec = unitSpecs.find((s, i) => { + return getMarkType(s.mark) === 'text' && s.encoding?.y && + ((s.encoding.y.datum ?? s.encoding.y.value) === yDatum); + }); + const ruleText = textSpec + ? String(textSpec.encoding?.text?.datum || textSpec.encoding?.text?.value || yDatum) + : String(yDatum); + + // Add the constant y-value line to every x-axis point + Object.keys(mapXToDataPoints).forEach(xKey => { + mapXToDataPoints[xKey].lineData!.push({ + y: yDatum as number, + legend: ruleText, + color: ruleColor, + ...(Object.keys(lineOptions).length > 0 && { lineOptions }), + useSecondaryYScale: false, + }); + }); + } + }); + + const chartData = Object.values(mapXToDataPoints); + const titles = getVegaLiteTitles(spec); + + // Check if we have secondary Y-axis data + const hasSecondaryYAxis = chartData.some(point => point.lineData?.some(line => line.useSecondaryYScale)); + + // Extract secondary Y-axis properties from line layers + let secondaryYAxisProps: Record = {}; + if (hasSecondaryYAxis && lineSpecs.length > 0) { + const lineSpec = lineSpecs[0]; + const lineEncoding = lineSpec.encoding || {}; + const lineYAxis = lineEncoding.y?.axis; + + if (lineYAxis?.title) { + secondaryYAxisProps.secondaryYAxistitle = lineYAxis.title; + } + + // Compute secondary Y scale domain from line data values + const allLineYValues: number[] = []; + chartData.forEach(point => { + point.lineData?.forEach(line => { + if (line.useSecondaryYScale && typeof line.y === 'number') { + allLineYValues.push(line.y); + } + }); + }); + + if (allLineYValues.length > 0) { + // Use explicit domain from line encoding if available, otherwise compute from data + const lineDomain = lineEncoding.y?.scale?.domain; + const secYMin = Array.isArray(lineDomain) ? (lineDomain[0] as number) : d3Min(allLineYValues) ?? 0; + const secYMax = Array.isArray(lineDomain) ? (lineDomain[1] as number) : d3Max(allLineYValues) ?? 0; + secondaryYAxisProps.secondaryYScaleOptions = { + yMinValue: secYMin, + yMaxValue: secYMax, + }; + } + } + + // Check for log scale on primary Y-axis + const yAxisType = extractYAxisType(encoding); + + // Extract y-axis formatting and domain props + const yAxisTickFormat = encoding.y?.axis?.format; + const yMinValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[0] as number) : undefined; + const yMaxValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[1] as number) : undefined; + + // Extract axis category ordering + const categoryOrderProps = extractAxisCategoryOrderProps(encoding); + + return { + data: chartData, + chartTitle: titles.chartTitle, + xAxisTitle: titles.xAxisTitle, + yAxisTitle: titles.yAxisTitle, + ...(titles.titleStyles ? titles.titleStyles : {}), + width: spec.width as number | undefined, + height: (spec.height as number | undefined) ?? DEFAULT_CHART_HEIGHT, + hideLegend: encoding.color?.legend?.disable ?? false, + showYAxisLables: true, + roundCorners: true, + hideTickOverlap: true, + barGapMax: 2, + noOfCharsToTruncate: DEFAULT_TRUNCATE_CHARS, + showYAxisLablesTooltip: true, + wrapXAxisLables: typeof chartData[0]?.xAxisPoint === 'string', + ...(yAxisTickFormat && { yAxisTickFormat }), + ...(yMinValue !== undefined && { yMinValue }), + ...(yMaxValue !== undefined && { yMaxValue }), + ...(yAxisType && { yScaleType: yAxisType }), + ...secondaryYAxisProps, + ...categoryOrderProps, + }; +} + +/** + * Transforms Vega-Lite specification to Fluent GroupedVerticalBarChart props + * + * Supports grouped bar charts with color encoding for grouping + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns GroupedVerticalBarChartProps for rendering + */ +export function transformVegaLiteToGroupedVerticalBarChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): GroupedVerticalBarChartProps { + // Initialize transformation context + const { dataValues, encoding } = initializeTransformContext(spec); + + // Extract field names + const { xField, yField, colorField } = extractEncodingFields(encoding); + + if (!xField || !yField || !colorField) { + throw new Error('VegaLiteSchemaAdapter: x, y, and color encodings are required for grouped bar charts'); + } + + // Extract color configuration + const { colorScheme, colorRange } = extractColorConfig(encoding); + + // Group data by x value (name), then by color (series) + const groupedData: { [key: string]: { [legend: string]: number } } = {}; + const colorIndex = new Map(); + let currentColorIndex = 0; + + dataValues.forEach(row => { + const xValue = row[xField]; + const yValue = row[yField]; + const groupValue = row[colorField]; + + if (isInvalidValue(xValue) || isInvalidValue(yValue) || typeof yValue !== 'number' || isInvalidValue(groupValue)) { + return; + } + + const xKey = String(xValue); + const legend = String(groupValue); + + if (!groupedData[xKey]) { + groupedData[xKey] = {}; + } + + groupedData[xKey][legend] = yValue; + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + }); + + // Convert to GroupedVerticalBarChartData format + const chartData = Object.keys(groupedData).map(name => { + const series = Object.keys(groupedData[name]).map(legend => ({ + key: legend, + data: groupedData[name][legend], + legend, + color: resolveColor(legend, colorIndex.get(legend)!, undefined, undefined, colorMap, colorScheme, colorRange, isDarkTheme), + })); + + return { + name, + series, + }; + }); + + const titles = getVegaLiteTitles(spec); + + // Extract y-axis formatting and scale props + const yAxisTickFormat = encoding.y?.axis?.format; + const yMinValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[0] as number) : undefined; + const yMaxValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[1] as number) : undefined; + const yAxisType = extractYAxisType(encoding); + + return { + data: chartData, + chartTitle: titles.chartTitle, + xAxisTitle: titles.xAxisTitle, + yAxisTitle: titles.yAxisTitle, + ...(titles.titleStyles ? titles.titleStyles : {}), + ...(yAxisTickFormat && { yAxisTickFormat }), + ...(yMinValue !== undefined && { yMinValue }), + ...(yMaxValue !== undefined && { yMaxValue }), + ...(yAxisType && { yScaleType: yAxisType }), + }; +} + +/** + * Transforms Vega-Lite specification to Fluent HorizontalBarChartWithAxis props + * + * Supports horizontal bar charts with quantitative x-axis and nominal/ordinal y-axis + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns HorizontalBarChartWithAxisProps for rendering + */ +export function transformVegaLiteToHorizontalBarChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): HorizontalBarChartWithAxisProps { + // Initialize transformation context + const { dataValues, encoding, markProps } = initializeTransformContext(spec); + + // Extract field names and aggregates + const { xField, yField, colorField, xAggregate, x2Field } = extractEncodingFields(encoding); + + // Check if this is an aggregate bar chart + // Aggregate can be: count (no field needed) or sum/mean/etc (with field) + const isAggregate = !!xAggregate; + + if (!yField && !isAggregate) { + throw new Error('VegaLiteSchemaAdapter: y encoding is required for horizontal bar charts'); + } + + // For aggregate charts, compute aggregated data + let aggregatedData: Array<{ category: string; value: number }> | undefined; + if (isAggregate && yField) { + aggregatedData = computeAggregateData(dataValues, yField, xField, xAggregate as string); + } + + const colorValue = encoding.color?.value as string | undefined; + const barData: HorizontalBarChartWithAxisDataPoint[] = []; + const colorIndex = new Map(); + let currentColorIndex = 0; + + if (aggregatedData) { + // Use aggregated data + aggregatedData.forEach(({ category, value }) => { + const legend = String(category); + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + + const color = resolveColor(legend, colorIndex.get(legend)!, colorValue, markProps.color, colorMap, undefined, undefined, isDarkTheme); + + barData.push({ + x: value, + y: category, + legend, + color, + }); + }); + } else if (x2Field && xField && yField) { + // Gantt chart: bar mark with x/x2 temporal range encoding + const isXTemporal = encoding.x?.type === 'temporal'; + dataValues.forEach(row => { + const startVal = row[xField]; + const endVal = row[x2Field]; + const yValue = row[yField]; + if (startVal === undefined || endVal === undefined || yValue === undefined) { + return; + } + + let xNumeric: number; + if (isXTemporal) { + const startDate = new Date(startVal as string | number); + const endDate = new Date(endVal as string | number); + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return; + } + xNumeric = Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + } else { + xNumeric = Number(endVal) - Number(startVal); + if (isNaN(xNumeric)) { + return; + } + } + + const legend = colorField && row[colorField] !== undefined ? String(row[colorField]) : String(yValue); + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + const color = resolveColor(legend, colorIndex.get(legend)!, colorValue, markProps.color, colorMap, undefined, undefined, isDarkTheme); + barData.push({ x: xNumeric, y: yValue as number | string, legend, color }); + }); + } else if (xField && yField) { + // Use raw data + dataValues.forEach(row => { + const xValue = row[xField]; + const yValue = row[yField]; + + if (isInvalidValue(xValue) || isInvalidValue(yValue) || typeof xValue !== 'number') { + return; + } + + const legend = colorField && row[colorField] !== undefined ? String(row[colorField]) : String(yValue); + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + + const color = resolveColor(legend, colorIndex.get(legend)!, colorValue, markProps.color, colorMap, undefined, undefined, isDarkTheme); + + barData.push({ + x: xValue, + y: yValue as number | string, + legend, + color, + }); + }); + } + + const titles = getVegaLiteTitles(spec); + const annotations = extractAnnotations(spec); + const tickConfig = extractTickConfig(spec); + + const result: HorizontalBarChartWithAxisProps = { + data: barData, + chartTitle: titles.chartTitle, + xAxisTitle: titles.xAxisTitle, + yAxisTitle: titles.yAxisTitle, + ...(titles.titleStyles ? titles.titleStyles : {}), + }; + + if (annotations.length > 0) { + result.annotations = annotations; + } + + if (tickConfig.tickValues) { + result.tickValues = tickConfig.tickValues as number[] | string[] | Date[]; + } + + if (tickConfig.xAxisTickCount) { + result.xAxisTickCount = tickConfig.xAxisTickCount; + } + + if (tickConfig.yAxisTickCount) { + result.yAxisTickCount = tickConfig.yAxisTickCount; + } + + return result; +} + +/** + * Transforms Vega-Lite specification to Fluent AreaChart props + * + * Area charts use the same data structure as line charts but with filled areas. + * Supports temporal/quantitative x-axis and quantitative y-axis with color-encoded series + * + * Vega-Lite Stacking Behavior: + * - If y.stack is null or undefined with no color encoding: mode = 'tozeroy' (fill to zero baseline) + * - If y.stack is 'zero' or color encoding exists: mode = 'tonexty' (stacked areas) + * - Multiple series with color encoding automatically stack + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns AreaChartProps for rendering + */ +export function transformVegaLiteToAreaChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): AreaChartProps { + // Area charts use the same structure as line charts in Fluent Charts + // The only difference is the component renders with filled areas + const lineChartProps = transformVegaLiteToLineChartProps(spec, colorMap, isDarkTheme); + + // Determine stacking mode based on Vega-Lite spec + const unitSpecs = normalizeSpec(spec); + // Use findPrimaryLineSpec to skip auxiliary layers (like rect for color fill bars) + const primarySpec = findPrimaryLineSpec(unitSpecs); + const encoding = primarySpec?.encoding || {}; + + // Check if stacking is enabled + // In Vega-Lite, area charts stack by default when color encoding is present + // stack can be explicitly set to null to disable stacking + const hasColorEncoding = !!encoding.color?.field; + const stackConfig = encoding.y?.stack; + const isStacked = stackConfig !== null && (stackConfig === 'zero' || hasColorEncoding); + + // Set mode: 'tozeroy' for single series, 'tonexty' for stacked + const mode: 'tozeroy' | 'tonexty' = isStacked ? 'tonexty' : 'tozeroy'; + + return { + ...lineChartProps, + mode, + // Cast needed: AreaChartProps and LineChartProps share the same base but have + // incompatible style types. The spread is safe because styles are not set here. + } as AreaChartProps; +} + +/** + * Transforms Vega-Lite specification to Fluent ScatterChart props + * + * Supports scatter plots with quantitative x and y axes and color-encoded series + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns ScatterChartProps for rendering + */ +export function transformVegaLiteToScatterChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): ScatterChartProps { + // Initialize transformation context + const { dataValues, encoding, markProps } = initializeTransformContext(spec); + + // Extract field names + const { xField, yField, colorField, sizeField } = extractEncodingFields(encoding); + + if (!xField || !yField) { + throw new Error('VegaLiteSchemaAdapter: Both x and y encodings are required for scatter charts'); + } + + const isXTemporal = encoding.x?.type === 'temporal'; + const isYTemporal = encoding.y?.type === 'temporal'; + + // Check if y-values are strings (nominal/ordinal) and build ordinal mapping + const yIsNominal = encoding.y?.type === 'nominal' || encoding.y?.type === 'ordinal'; + const yOrdinalMap = new Map(); + const yOrdinalLabels: string[] = []; + + if (yIsNominal) { + // Collect unique y-values in order + dataValues.forEach(row => { + const yVal = row[yField]; + if (yVal !== undefined) { + const key = String(yVal); + if (!yOrdinalMap.has(key)) { + yOrdinalMap.set(key, yOrdinalMap.size); + yOrdinalLabels.push(key); + } + } + }); + } + + // Group data by series (color encoding) + const groupedData: Record>> = {}; + + dataValues.forEach(row => { + const seriesName = colorField && row[colorField] !== undefined ? String(row[colorField]) : 'default'; + + if (!groupedData[seriesName]) { + groupedData[seriesName] = []; + } + + groupedData[seriesName].push(row); + }); + + const seriesNames = Object.keys(groupedData); + const colorIndex = new Map(); + let currentColorIndex = 0; + + const chartData: LineChartPoints[] = seriesNames.map((seriesName, index) => { + if (!colorIndex.has(seriesName)) { colorIndex.set(seriesName, currentColorIndex++); } + const seriesData = groupedData[seriesName]; + + const points: ScatterChartDataPoint[] = seriesData.map(row => { + const xValue = parseValue(row[xField], isXTemporal); + const yValue = parseValue(row[yField], isYTemporal); + const markerSize = sizeField && row[sizeField] !== undefined ? Number(row[sizeField]) : undefined; + + // Map nominal y-values to numeric indices + let numericY: number; + if (yIsNominal && typeof yValue === 'string') { + numericY = yOrdinalMap.get(yValue) ?? 0; + } else { + numericY = typeof yValue === 'number' ? yValue : 0; + } + + return { + x: typeof xValue === 'number' || xValue instanceof Date ? xValue : String(xValue), + y: numericY, + ...(markerSize !== undefined && { markerSize }), + }; + }); + + // Get color for this series + const colorValue = + colorField && encoding.color?.scale?.range && Array.isArray(encoding.color.scale.range) + ? encoding.color.scale.range[index] + : markProps.color; + const color = typeof colorValue === 'string' ? colorValue : resolveColor(seriesName, colorIndex.get(seriesName)!, undefined, undefined, colorMap, undefined, undefined, isDarkTheme); + + return { + legend: seriesName, + data: points, + color, + legendShape: 'circle' as const, + }; + }); + + const titles = getVegaLiteTitles(spec); + const annotations = extractAnnotations(spec); + const tickConfig = extractTickConfig(spec); + + // Check for log scale on Y-axis + const yAxisType = extractYAxisType(encoding); + + // Extract y-axis formatting and domain props + const yAxisTickFormat = encoding.y?.axis?.format; + const yMinValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[0] as number) : undefined; + const yMaxValue = Array.isArray(encoding.y?.scale?.domain) ? (encoding.y.scale.domain[1] as number) : undefined; + + // Extract axis category ordering + const categoryOrderProps = extractAxisCategoryOrderProps(encoding); + + const result: ScatterChartProps = { + data: { + chartTitle: titles.chartTitle, + scatterChartData: chartData, + }, + xAxisTitle: titles.xAxisTitle, + yAxisTitle: titles.yAxisTitle, + ...(titles.titleStyles ? titles.titleStyles : {}), + ...(yAxisTickFormat && { yAxisTickFormat }), + ...(yMinValue !== undefined && { yMinValue }), + ...(yMaxValue !== undefined && { yMaxValue }), + ...(yAxisType && { yScaleType: yAxisType }), + // For nominal y-axis, provide tick values and labels + ...(yIsNominal && yOrdinalLabels.length > 0 && { + yAxisTickValues: Array.from({ length: yOrdinalLabels.length }, (_, i) => i), + yAxisTickFormat: (val: number) => yOrdinalLabels[val] ?? String(val), + yMinValue: -0.5, + yMaxValue: yOrdinalLabels.length - 0.5, + }), + ...categoryOrderProps, + }; + + if (annotations.length > 0) { + result.annotations = annotations; + } + + if (tickConfig.tickValues) { + result.tickValues = tickConfig.tickValues as number[] | string[] | Date[]; + } + + if (tickConfig.xAxisTickCount) { + result.xAxisTickCount = tickConfig.xAxisTickCount; + } + + if (tickConfig.yAxisTickCount) { + result.yAxisTickCount = tickConfig.yAxisTickCount; + } + + return result; +} + +/** + * Transforms Vega-Lite specification to Fluent DonutChart props + * + * Supports pie/donut charts with arc marks and theta encoding + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns DonutChartProps for rendering + */ +export function transformVegaLiteToDonutChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): DonutChartProps { + // Initialize transformation context + const { dataValues, encoding, primarySpec } = initializeTransformContext(spec); + + // Extract field names + const { thetaField, colorField } = extractEncodingFields(encoding); + + if (!thetaField) { + throw new Error('VegaLiteSchemaAdapter: Theta encoding is required for donut charts'); + } + + // Extract color configuration + const { colorScheme, colorRange } = extractColorConfig(encoding); + + // Extract innerRadius from mark properties if available + const mark = primarySpec.mark; + const innerRadius = typeof mark === 'object' && mark?.innerRadius !== undefined ? mark.innerRadius : 0; + + const chartData: ChartDataPoint[] = []; + const colorIndex = new Map(); + let currentColorIndex = 0; + + dataValues.forEach(row => { + const value = row[thetaField]; + const legend = colorField && row[colorField] !== undefined ? String(row[colorField]) : String(value); + + if (value === undefined || typeof value !== 'number') { + return; + } + + if (!colorIndex.has(legend)) { + colorIndex.set(legend, currentColorIndex++); + } + + chartData.push({ + legend, + data: value, + color: resolveColor(legend, colorIndex.get(legend)!, undefined, undefined, colorMap, colorScheme, colorRange, isDarkTheme), + }); + }); + + const titles = getVegaLiteTitles(spec); + + return { + data: { + chartTitle: titles.chartTitle, + chartData, + }, + innerRadius, + width: typeof spec.width === 'number' ? spec.width : undefined, + height: typeof spec.height === 'number' ? spec.height : undefined, + ...(titles.titleStyles ? titles.titleStyles : {}), + }; +} + +/** + * Transforms Vega-Lite specification to Fluent HeatMapChart props + * + * Supports heatmaps with rect marks and x/y/color encodings + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns HeatMapChartProps for rendering + */ +export function transformVegaLiteToHeatMapChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): HeatMapChartProps { + // Initialize transformation context + const { dataValues, encoding } = initializeTransformContext(spec); + + // Extract field names + const { xField, yField, colorField } = extractEncodingFields(encoding); + + if (!xField || !yField || !colorField) { + throw new Error('VegaLiteSchemaAdapter: x, y, and color encodings are required for heatmap charts'); + } + + const heatmapDataPoints: HeatMapChartDataPoint[] = []; + let minValue = Number.POSITIVE_INFINITY; + let maxValue = Number.NEGATIVE_INFINITY; + + // Check if color values are nominal (strings) rather than quantitative (numbers) + const isNominalColor = encoding.color?.type === 'nominal' || encoding.color?.type === 'ordinal' || + dataValues.some(row => row[colorField] !== undefined && typeof row[colorField] !== 'number'); + const nominalColorMap = new Map(); + + dataValues.forEach(row => { + const xValue = row[xField]; + const yValue = row[yField]; + const colorValue = row[colorField]; + + if (isInvalidValue(xValue) || isInvalidValue(yValue) || isInvalidValue(colorValue)) { + return; + } + + let value: number; + if (isNominalColor) { + // Map nominal color values to sequential numeric indices + const key = String(colorValue); + if (!nominalColorMap.has(key)) { + nominalColorMap.set(key, nominalColorMap.size); + } + value = nominalColorMap.get(key)!; + } else { + value = typeof colorValue === 'number' ? colorValue : 0; + } + + minValue = Math.min(minValue, value); + maxValue = Math.max(maxValue, value); + + heatmapDataPoints.push({ + x: xValue as string | Date | number, + y: yValue as string | Date | number, + value, + rectText: isNominalColor ? String(colorValue) : value, + }); + }); + + // Validate that we have complete grid data + if (heatmapDataPoints.length === 0) { + throw new Error('VegaLiteSchemaAdapter: Heatmap requires data points with x, y, and color values'); + } + + // Extract unique x and y values and create complete grid + const uniqueXValues = new Set(heatmapDataPoints.map(p => String(p.x))); + const uniqueYValues = new Set(heatmapDataPoints.map(p => String(p.y))); + + // Build a map of existing data points for quick lookup + const dataPointMap = new Map(); + const rectTextMap = new Map(); + heatmapDataPoints.forEach(point => { + const key = `${String(point.x)}|${String(point.y)}`; + dataPointMap.set(key, point.value); + rectTextMap.set(key, point.rectText); + }); + + // Generate complete grid - fill missing cells with 0 + const completeGridDataPoints: HeatMapChartDataPoint[] = []; + let xValuesArray = Array.from(uniqueXValues); + const yValuesArray = Array.from(uniqueYValues); + + // Sort x-values chronologically if they appear to be dates + const isXTemporal = encoding.x?.type === 'temporal' || encoding.x?.type === 'ordinal'; + if (isXTemporal) { + const firstX = xValuesArray[0]; + const parsedDate = new Date(firstX); + if (!isNaN(parsedDate.getTime())) { + // Values are parseable as dates — sort chronologically + xValuesArray = xValuesArray.sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + } + } + + yValuesArray.forEach(yVal => { + xValuesArray.forEach(xVal => { + const key = `${xVal}|${yVal}`; + const value = dataPointMap.get(key) ?? 0; // Use 0 for missing cells + + // Update min/max to include filled values + if (value !== 0 || dataPointMap.has(key)) { + minValue = Math.min(minValue, value); + maxValue = Math.max(maxValue, value); + } + + completeGridDataPoints.push({ + x: xVal, + y: yVal, + value, + rectText: rectTextMap.get(key) ?? value, + }); + }); + }); + + const heatmapData: HeatMapChartData = { + legend: '', + data: completeGridDataPoints, + value: 0, + }; + + const titles = getVegaLiteTitles(spec); + + // Create color scale domain and range + let domainValues: number[] = []; + let rangeValues: string[] = []; + + // Check for named color scheme or custom range from encoding + const colorScheme = encoding.color?.scale?.scheme as string | undefined; + const customRange = encoding.color?.scale?.range as string[] | undefined; + + if (isNominalColor && nominalColorMap.size > 0) { + // For nominal colors, use categorical color scale + const numCategories = nominalColorMap.size; + domainValues = Array.from({ length: numCategories }, (_, i) => i); + + if (customRange && customRange.length >= numCategories) { + rangeValues = customRange.slice(0, numCategories); + } else { + // Use distinct categorical colors for each category + for (let i = 0; i < numCategories; i++) { + rangeValues.push(getVegaColor(i, colorScheme, customRange, isDarkTheme ?? false)); + } + } + } else { + // Quantitative color scale + const steps = 5; + for (let i = 0; i < steps; i++) { + const t = i / (steps - 1); + domainValues.push(minValue + (maxValue - minValue) * t); + } + + if (customRange && customRange.length > 0) { + rangeValues = customRange.length >= steps + ? customRange.slice(0, steps) + : customRange; + } else if (colorScheme) { + const schemeColors = getSequentialSchemeColors(colorScheme, steps); + if (schemeColors) { + const isReversed = encoding.color?.sort === 'descending' || + (encoding.color?.scale as Record)?.reverse === true; + rangeValues = isReversed ? schemeColors.reverse() : schemeColors; + } + } + + // Fall back to default blue-to-red gradient if no scheme matched + if (rangeValues.length === 0) { + for (let i = 0; i < steps; i++) { + const t = i / (steps - 1); + if (isDarkTheme) { + const r = Math.round(0 + 255 * t); + const g = Math.round(100 + (165 - 100) * t); + const b = Math.round(255 - 255 * t); + rangeValues.push(`rgb(${r}, ${g}, ${b})`); + } else { + const r = Math.round(0 + 255 * t); + const g = Math.round(150 - 150 * t); + const b = Math.round(255 - 255 * t); + rangeValues.push(`rgb(${r}, ${g}, ${b})`); + } + } + } + } + + return { + chartTitle: titles.chartTitle, + data: [heatmapData], + domainValuesForColorScale: domainValues, + rangeValuesForColorScale: rangeValues, + xAxisTitle: titles.xAxisTitle, + yAxisTitle: titles.yAxisTitle, + ...(titles.titleStyles ? titles.titleStyles : {}), + width: spec.width as number | undefined, + height: (spec.height as number | undefined) ?? DEFAULT_CHART_HEIGHT, + hideLegend: true, + showYAxisLables: true, + sortOrder: 'none', + hideTickOverlap: true, + noOfCharsToTruncate: xValuesArray.length > 20 ? 6 : xValuesArray.length > 10 ? 10 : DEFAULT_TRUNCATE_CHARS, + showYAxisLablesTooltip: true, + wrapXAxisLables: true, + }; +} + +/** + * Helper function to get bin center for display + */ +function getBinCenter(bin: Bin): number { + return (bin.x0! + bin.x1!) / 2; +} + +/** + * Helper function to calculate histogram aggregation function + * + * @param aggregate - Aggregation type (count, sum, mean, min, max) + * @param bin - Binned data values + * @returns Aggregated value + */ +function calculateHistogramAggregate( + aggregate: 'count' | 'sum' | 'mean' | 'average' | 'median' | 'min' | 'max' | undefined, + bin: number[], +): number { + switch (aggregate) { + case 'sum': + return d3Sum(bin); + case 'mean': + case 'average': + return bin.length === 0 ? 0 : d3Mean(bin) ?? 0; + case 'min': + return d3Min(bin) ?? 0; + case 'max': + return d3Max(bin) ?? 0; + case 'count': + default: + return bin.length; + } +} + +/** + * Transforms Vega-Lite specification to Fluent VerticalBarChart props for histogram rendering + * + * Supports histograms with binned x-axis and aggregated y-axis + * Vega-Lite syntax: `{ "mark": "bar", "encoding": { "x": { "field": "value", "bin": true }, "y": { "aggregate": "count" } } }` + * + * @param spec - Vega-Lite specification + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns VerticalBarChartProps for rendering histogram + */ +export function transformVegaLiteToHistogramProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): VerticalBarChartProps { + // Initialize transformation context + const { dataValues, encoding } = initializeTransformContext(spec); + + // Extract field names + const { xField } = extractEncodingFields(encoding); + const yAggregate = encoding.y?.aggregate || 'count'; + const binConfig = encoding.x?.bin; + + if (!xField || !binConfig) { + throw new Error('VegaLiteSchemaAdapter: Histogram requires x encoding with bin property'); + } + + // Validate data + validateDataArray(dataValues, xField, 'Histogram'); + validateNoNestedArrays(dataValues, xField); + + // Extract numeric values from the field + const allValues = dataValues.map(row => row[xField]).filter(val => !isInvalidValue(val)); + const values = allValues.filter(val => typeof val === 'number') as number[]; + + if (values.length === 0) { + // Provide helpful error message based on actual data type + const sampleValue = allValues[0]; + const actualType = typeof sampleValue; + let suggestion = ''; + + if (actualType === 'string') { + // Check if strings contain numbers + const hasEmbeddedNumbers = allValues.some(val => typeof val === 'string' && /\d/.test(val)); + if (hasEmbeddedNumbers) { + suggestion = + ' The data contains strings with embedded numbers (e.g., "40 salads"). ' + + 'Consider extracting the numeric values first, or change the encoding type to "nominal" or "ordinal" for a categorical bar chart.'; + } else { + suggestion = + ` The data contains categorical strings (e.g., "${sampleValue}"). ` + + 'Change the x encoding type to "nominal" or "ordinal" for a categorical bar chart, ' + + 'or remove bin: true to create a simple bar chart.'; + } + } else if (actualType === 'undefined') { + suggestion = ' The field may not exist in the data.'; + } + + throw new Error( + `VegaLiteSchemaAdapter: No numeric values found for histogram binning on field "${xField}". ` + + `Found ${actualType} values instead.${suggestion}`, + ); + } + + // Create bins using d3 + const [minVal, maxVal] = d3Extent(values) as [number, number]; + const binGenerator = d3Bin().domain([minVal, maxVal]); + + // Apply bin configuration + if (typeof binConfig === 'object') { + if (binConfig.maxbins) { + binGenerator.thresholds(binConfig.maxbins); + } + if (binConfig.extent) { + binGenerator.domain(binConfig.extent); + } + } + + const bins = binGenerator(values); + + // Calculate histogram data points + const legend = encoding.color?.field ? String(dataValues[0]?.[encoding.color.field]) : 'Frequency'; + const color = resolveColor(legend, 0, undefined, undefined, colorMap, undefined, undefined, isDarkTheme); + const yField = encoding.y?.field; + + const histogramData: VerticalBarChartDataPoint[] = bins.map(bin => { + const x = getBinCenter(bin); + let y: number; + + if (yAggregate !== 'count' && yField) { + // For non-count aggregates, collect y-field values for rows whose x-value falls in this bin + const yValues = dataValues + .filter(row => { + const xVal = Number(row[xField]); + return !isNaN(xVal) && xVal >= bin.x0! && xVal < bin.x1!; + }) + .map(row => Number(row[yField])) + .filter(v => !isNaN(v)); + // Include the last bin's upper bound (x1 is inclusive for the last bin) + if (bin === bins[bins.length - 1]) { + const extraRows = dataValues + .filter(row => Number(row[xField]) === bin.x1!) + .map(row => Number(row[yField])) + .filter(v => !isNaN(v)); + yValues.push(...extraRows); + } + y = calculateHistogramAggregate(yAggregate, yValues); + } else { + y = calculateHistogramAggregate(yAggregate, bin); + } + + const xAxisCalloutData = `[${bin.x0} - ${bin.x1})`; + + return { + x, + y, + legend, + color, + xAxisCalloutData, + }; + }); + + const titles = getVegaLiteTitles(spec); + const annotations = extractAnnotations(spec); + const yAxisTickFormat = encoding.y?.axis?.format; + + return { + data: histogramData, + chartTitle: titles.chartTitle, + xAxisTitle: titles.xAxisTitle || xField, + yAxisTitle: titles.yAxisTitle || yAggregate, + ...(titles.titleStyles ? titles.titleStyles : {}), + roundCorners: true, + hideTickOverlap: true, + maxBarWidth: DEFAULT_MAX_BAR_WIDTH, + ...(annotations.length > 0 && { annotations }), + ...(yAxisTickFormat && { yAxisTickFormat }), + mode: 'histogram', + }; +} + +/** + * Transforms Vega-Lite specification with theta/radius encodings to Fluent PolarChart props + * Supports line, point, and area marks with polar coordinates + * + * @param spec - Vega-Lite specification with theta and radius encodings + * @param colorMap - Color mapping ref for consistent coloring + * @param isDarkTheme - Whether dark theme is active + * @returns PolarChartProps for rendering with Fluent PolarChart component + */ +export function transformVegaLiteToPolarChartProps( + spec: VegaLiteSpec, + colorMap: ColorMapRef, + isDarkTheme?: boolean, +): PolarChartProps { + // Initialize transformation context + const { dataValues, encoding, markProps, primarySpec } = initializeTransformContext(spec); + + // Extract field names + const { thetaField, radiusField, colorField } = extractEncodingFields(encoding); + + // Validate polar encodings + if (!thetaField || !radiusField) { + throw new Error('VegaLiteSchemaAdapter: Both theta and radius encodings are required for polar charts'); + } + + validateDataArray(dataValues, thetaField, 'PolarChart'); + validateDataArray(dataValues, radiusField, 'PolarChart'); + + // Determine mark type for polar chart series type + const mark = primarySpec.mark; + const markType = typeof mark === 'string' ? mark : mark?.type; + // Arc marks with theta+radius should be treated as area polar (radial/rose charts) + const isAreaMark = markType === 'area' || markType === 'arc'; + const isLineMark = markType === 'line'; + + // Extract color configuration + const { colorScheme, colorRange } = extractColorConfig(encoding); + + // Group data by series (color field) + const seriesMap = new Map(); + const colorIndex = new Map(); + let currentColorIndex = 0; + + dataValues.forEach(row => { + const thetaValue = row[thetaField]; + const radiusValue = row[radiusField]; + + // Skip invalid values + if (isInvalidValue(thetaValue) || isInvalidValue(radiusValue)) { + return; + } + + const seriesName = colorField && row[colorField] !== undefined ? String(row[colorField]) : 'default'; + + if (!colorIndex.has(seriesName)) { + colorIndex.set(seriesName, currentColorIndex++); + } + + if (!seriesMap.has(seriesName)) { + seriesMap.set(seriesName, []); + } + + // Convert theta value - handle different types + let theta: string | number; + if (typeof thetaValue === 'number') { + // Numeric theta - assume degrees + theta = thetaValue; + } else { + // Categorical theta + theta = String(thetaValue); + } + + // Convert radius value + const r = typeof radiusValue === 'number' ? radiusValue : Number(radiusValue); + + seriesMap.get(seriesName)!.push({ + theta, + r, + }); + }); + + // Convert series map to polar chart data array + const polarData: (AreaPolarSeries | LinePolarSeries | ScatterPolarSeries)[] = []; + + seriesMap.forEach((dataPoints, seriesName) => { + const color = resolveColor(seriesName, colorIndex.get(seriesName)!, undefined, markProps.color, colorMap, colorScheme, colorRange, isDarkTheme); + const curveOption = mapInterpolateToCurve(markProps.interpolate); + + // Build line options with curve, strokeDash, and strokeWidth + const lineOptions: Partial = {}; + if (curveOption) { + lineOptions.curve = curveOption; + } + if (markProps.strokeDash) { + lineOptions.strokeDasharray = markProps.strokeDash.join(' '); + } + if (markProps.strokeWidth) { + lineOptions.strokeWidth = markProps.strokeWidth; + } + + if (isAreaMark) { + const series: AreaPolarSeries = { + type: 'areapolar', + legend: seriesName, + color, + data: dataPoints, + ...(Object.keys(lineOptions).length > 0 && { lineOptions }), + }; + polarData.push(series); + } else if (isLineMark) { + const series: LinePolarSeries = { + type: 'linepolar', + legend: seriesName, + color, + data: dataPoints, + ...(Object.keys(lineOptions).length > 0 && { lineOptions }), + }; + polarData.push(series); + } else { + // Default to scatter polar for point marks + const series: ScatterPolarSeries = { + type: 'scatterpolar', + legend: seriesName, + color, + data: dataPoints, + }; + polarData.push(series); + } + }); + + // Extract chart titles + const titles = getVegaLiteTitles(spec); + + // Build axis props from encoding + const radialAxis: PolarAxisProps = {}; + const angularAxis: PolarAxisProps & { unit?: 'radians' | 'degrees' } = {}; + + // Determine angular axis category order if theta is categorical + const thetaType = encoding.theta?.type; + if (thetaType === 'nominal' || thetaType === 'ordinal') { + // Get unique theta values in order for category order + const thetaValues = Array.from(new Set(dataValues.map(row => String(row[thetaField])))); + angularAxis.categoryOrder = thetaValues as unknown as AxisCategoryOrder; + } + + return { + data: polarData, + ...(titles.chartTitle && { chartTitle: titles.chartTitle }), + ...(titles.titleStyles ? titles.titleStyles : {}), + width: typeof spec.width === 'number' ? spec.width : undefined, + height: typeof spec.height === 'number' ? spec.height : 400, + hideLegend: encoding.color?.legend?.disable ?? false, + radialAxis, + angularAxis, + }; +} diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapterUT.test.tsx b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapterUT.test.tsx new file mode 100644 index 00000000000000..17bff9fbd6aec5 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapterUT.test.tsx @@ -0,0 +1,1466 @@ +import { + transformVegaLiteToLineChartProps, + transformVegaLiteToVerticalBarChartProps, + transformVegaLiteToHistogramProps, + transformVegaLiteToPolarChartProps, + getVegaLiteLegendsProps, + getVegaLiteTitles, +} from './VegaLiteSchemaAdapter'; +import type { VegaLiteSpec } from './VegaLiteTypes'; + +const colorMap = new Map(); + +describe('VegaLiteSchemaAdapter', () => { + beforeEach(() => { + // Clear colorMap before each test to ensure test isolation + colorMap.clear(); + }); + + describe('transformVegaLiteToLineChartProps', () => { + test('Should transform basic line chart with quantitative axes', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + { x: 3, y: 43 }, + { x: 4, y: 91 }, + { x: 5, y: 81 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.data.lineChartData).toHaveLength(1); + expect(result.data.lineChartData![0].data).toHaveLength(5); + expect(result.data.lineChartData![0].legend).toBe('default'); + }); + + test('Should transform line chart with temporal x-axis', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { date: '2024-01-01', value: 100 }, + { date: '2024-02-01', value: 150 }, + { date: '2024-03-01', value: 120 }, + { date: '2024-04-01', value: 180 }, + ], + }, + encoding: { + x: { field: 'date', type: 'temporal', axis: { title: 'Date' } }, + y: { field: 'value', type: 'quantitative', axis: { title: 'Value' } }, + }, + title: 'Time Series Chart', + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.data.lineChartData).toHaveLength(1); + expect(result.data.lineChartData![0].data[0].x).toBeInstanceOf(Date); + expect(result.yAxisTitle).toBe('Value'); + }); + + test('Should transform multi-series chart with color encoding', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28, category: 'A' }, + { x: 2, y: 55, category: 'A' }, + { x: 3, y: 43, category: 'A' }, + { x: 1, y: 35, category: 'B' }, + { x: 2, y: 60, category: 'B' }, + { x: 3, y: 50, category: 'B' }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.data.lineChartData).toHaveLength(2); + expect(result.data.lineChartData![0].legend).toBe('A'); + expect(result.data.lineChartData![1].legend).toBe('B'); + expect(result.data.lineChartData![0].data).toHaveLength(3); + expect(result.data.lineChartData![1].data).toHaveLength(3); + }); + + test('Should transform layered spec with line and point marks', () => { + const spec: VegaLiteSpec = { + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + { x: 3, y: 43 }, + ], + }, + layer: [ + { + mark: 'line', + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }, + { + mark: 'point', + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }, + ], + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.data.lineChartData).toHaveLength(1); + expect(result.data.lineChartData![0].data).toHaveLength(3); + }); + + test('Should extract axis titles and formats', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 100 }, + { x: 2, y: 200 }, + ], + }, + encoding: { + x: { + field: 'x', + type: 'quantitative', + axis: { title: 'X Axis', format: '.0f' }, + }, + y: { + field: 'y', + type: 'quantitative', + axis: { title: 'Y Axis', format: '.2f', tickCount: 5 }, + }, + }, + title: 'Chart with Formats', + width: 800, + height: 400, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.tickFormat).toBe('.0f'); + expect(result.yAxisTickFormat).toBe('.2f'); + expect(result.yAxisTickCount).toBe(5); + expect(result.width).toBe(800); + expect(result.height).toBe(400); + }); + + test('Should handle interpolation mapping', () => { + const spec: VegaLiteSpec = { + mark: { + type: 'line', + interpolate: 'monotone', + }, + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + // Vega-Lite 'monotone' maps to 'linear' in Fluent Charts (closest approximation) + expect(result.data.lineChartData![0].lineOptions?.curve).toBe('linear'); + }); + + test('Should handle y-axis domain/range', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 50 }, + { x: 2, y: 150 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { + field: 'y', + type: 'quantitative', + scale: { domain: [0, 200] }, + }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.yMinValue).toBe(0); + expect(result.yMaxValue).toBe(200); + }); + + test('Should hide legend when disabled', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28, category: 'A' }, + { x: 2, y: 55, category: 'B' }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { field: 'category', type: 'nominal', legend: { disable: true } }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.hideLegend).toBe(true); + }); + + test('Should throw error for empty spec', () => { + const spec: VegaLiteSpec = {}; + + expect(() => transformVegaLiteToLineChartProps(spec, { current: colorMap }, false)).toThrow( + 'No valid unit specs found', + ); + }); + }); + + describe('getVegaLiteLegendsProps', () => { + test('Should generate legends for multi-series chart', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28, series: 'Alpha' }, + { x: 2, y: 55, series: 'Beta' }, + { x: 3, y: 43, series: 'Gamma' }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + }; + + const result = getVegaLiteLegendsProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.legends).toHaveLength(3); + expect(result.legends.map(l => l.title)).toContain('Alpha'); + expect(result.legends.map(l => l.title)).toContain('Beta'); + expect(result.legends.map(l => l.title)).toContain('Gamma'); + expect(result.canSelectMultipleLegends).toBe(true); + }); + + test('Should return empty legends when no color encoding', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const result = getVegaLiteLegendsProps(spec, { current: colorMap }, false); + + expect(result).toMatchSnapshot(); + expect(result.legends).toHaveLength(0); + }); + }); + + describe('getVegaLiteTitles', () => { + test('Should extract chart and axis titles', () => { + const spec: VegaLiteSpec = { + title: 'Sales Over Time', + mark: 'line', + data: { values: [] }, + encoding: { + x: { field: 'month', type: 'temporal', axis: { title: 'Month' } }, + y: { field: 'sales', type: 'quantitative', axis: { title: 'Sales ($)' } }, + }, + }; + + const result = getVegaLiteTitles(spec); + + expect(result).toMatchSnapshot(); + expect(result.chartTitle).toBe('Sales Over Time'); + expect(result.xAxisTitle).toBe('Month'); + expect(result.yAxisTitle).toBe('Sales ($)'); + }); + + test('Should handle object-form title', () => { + const spec: VegaLiteSpec = { + title: { text: 'Main Title', subtitle: 'Subtitle' }, + mark: 'line', + data: { values: [] }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const result = getVegaLiteTitles(spec); + + expect(result).toMatchSnapshot(); + expect(result.chartTitle).toBe('Main Title'); + }); + + test('Should return empty titles for minimal spec', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { values: [] }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const result = getVegaLiteTitles(spec); + + expect(result).toMatchSnapshot(); + expect(result.chartTitle).toBeUndefined(); + expect(result.xAxisTitle).toBeUndefined(); + expect(result.yAxisTitle).toBeUndefined(); + }); + }); + + describe('Data Validation', () => { + describe('Empty Data Validation', () => { + test('Should throw error for empty data array in LineChart', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { values: [] }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + expect(() => transformVegaLiteToLineChartProps(spec, { current: colorMap }, false)).toThrow( + 'Empty data array for LineChart', + ); + }); + + test('Should throw error for empty data array in VerticalBarChart', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { values: [] }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + expect(() => transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false)).toThrow( + 'Empty data array for VerticalBarChart', + ); + }); + + test('Should throw error for data with no valid values in specified field', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [{ x: null, y: 10 }, { x: undefined, y: 20 }, { y: 30 }], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + expect(() => transformVegaLiteToLineChartProps(spec, { current: colorMap }, false)).toThrow( + "No valid values found for field 'x' in LineChart", + ); + }); + }); + + describe('Null/Undefined Value Handling', () => { + test('Should gracefully skip null and undefined values in data', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: null }, + { x: null, y: 43 }, + { x: 3, y: undefined }, + { x: 4, y: 91 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + // Should only include the two valid data points + expect(result.data.lineChartData![0].data).toHaveLength(2); + expect(result.data.lineChartData![0].data[0].x).toBe(1); + expect(result.data.lineChartData![0].data[0].y).toBe(28); + expect(result.data.lineChartData![0].data[1].x).toBe(4); + expect(result.data.lineChartData![0].data[1].y).toBe(91); + }); + + test('Should skip NaN and Infinity values', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: NaN }, + { x: 3, y: Infinity }, + { x: 4, y: -Infinity }, + { x: 5, y: 81 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + // Should only include valid numeric values + expect(result.data.lineChartData![0].data).toHaveLength(2); + expect(result.data.lineChartData![0].data[0].y).toBe(28); + expect(result.data.lineChartData![0].data[1].y).toBe(81); + }); + }); + + describe('Nested Array Detection', () => { + test('Should throw error for nested arrays in x field', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: [1, 2, 3], y: 28 }, + { x: [4, 5], y: 55 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + expect(() => transformVegaLiteToLineChartProps(spec, { current: colorMap }, false)).toThrow( + "Nested arrays not supported for field 'x'", + ); + }); + + test('Should throw error for nested arrays in y field', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', value: [10, 20] }, + { category: 'B', value: [30, 40] }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + expect(() => transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false)).toThrow( + "Nested arrays not supported for field 'value'", + ); + }); + }); + + describe('Encoding Type Validation', () => { + test('Should auto-correct quantitative encoding with string values to nominal', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 'one', y: 28 }, + { x: 'two', y: 55 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + // Should auto-correct quantitative to nominal when data contains strings + // This matches Plotly behavior - render as categorical chart + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + // Should successfully transform (not throw error) + expect(result).toBeDefined(); + expect(result.data).toBeDefined(); + + // Type should be auto-corrected to nominal + expect(spec.encoding.x?.type).toBe('nominal'); + }); + + test('Should throw error for temporal encoding with invalid date strings', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { date: 'not-a-date', value: 100 }, + { date: 'invalid', value: 150 }, + ], + }, + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + expect(() => transformVegaLiteToLineChartProps(spec, { current: colorMap }, false)).toThrow( + "Field 'date' marked as temporal but contains invalid date values", + ); + }); + + test('Should accept valid temporal values', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { date: '2024-01-01', value: 100 }, + { date: '2024-02-01', value: 150 }, + ], + }, + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + expect(result.data.lineChartData).toHaveLength(1); + expect(result.data.lineChartData![0].data).toHaveLength(2); + }); + + test('Should accept nominal encoding with any values', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', value: 10 }, + { category: 123, value: 20 }, + { category: true, value: 30 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + expect(result.data).toHaveLength(3); + }); + }); + + describe('Encoding Compatibility Validation', () => { + test('Should render bar chart with quantitative axes by treating x as categories', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + // Bar charts with quantitative axes render by converting x to categorical strings + const result = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + expect(result.data).toHaveLength(2); + expect(result.data[0].x).toBe('1'); + expect(result.data[0].y).toBe(28); + expect(result.data[1].x).toBe('2'); + expect(result.data[1].y).toBe(55); + }); + + test('Should accept bar chart with nominal x-axis', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + expect(result.data).toHaveLength(2); + }); + + test('Should accept bar chart with ordinal x-axis', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'low', value: 10 }, + { category: 'medium', value: 20 }, + { category: 'high', value: 30 }, + ], + }, + encoding: { + x: { field: 'category', type: 'ordinal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + const result = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + expect(result.data).toHaveLength(3); + }); + }); + + describe('Histogram-Specific Validation', () => { + test('Should throw error for histogram without numeric values', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [{ value: 'text1' }, { value: 'text2' }], + }, + encoding: { + x: { field: 'value', bin: true }, + y: { aggregate: 'count' }, + }, + }; + + expect(() => transformVegaLiteToHistogramProps(spec, { current: colorMap }, false)).toThrow( + 'No numeric values found for histogram binning', + ); + }); + + test('Should accept histogram with valid numeric values', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [{ value: 10 }, { value: 20 }, { value: 30 }, { value: 25 }, { value: 15 }], + }, + encoding: { + x: { field: 'value', bin: true }, + y: { aggregate: 'count' }, + }, + }; + + const result = transformVegaLiteToHistogramProps(spec, { current: colorMap }, false); + expect(result.data).toBeDefined(); + expect(result.data!.length).toBeGreaterThan(0); + }); + + test('Should filter out invalid values before binning', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { value: 10 }, + { value: null }, + { value: 20 }, + { value: undefined }, + { value: NaN }, + { value: 30 }, + ], + }, + encoding: { + x: { field: 'value', bin: true }, + y: { aggregate: 'count' }, + }, + }; + + const result = transformVegaLiteToHistogramProps(spec, { current: colorMap }, false); + // Should only bin the 3 valid numeric values + expect(result.data).toBeDefined(); + expect(result.data!.length).toBeGreaterThan(0); + }); + }); + + // Skipped: These tests expect console.warn calls that are intentionally not emitted + // to avoid polluting console output. The unsupported features are documented in comments. + describe.skip('Unsupported Features Warnings', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { + // Mock implementation + }); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + test('Should warn about transform pipeline', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + ], + }, + transform: [{ filter: 'datum.y > 30' }], + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Transform pipeline is not yet supported')); + }); + + test('Should warn about selections', () => { + const spec: any = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + ], + }, + selection: { + brush: { type: 'interval' }, + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Interactive selections are not yet supported'), + ); + }); + + test('Should warn about repeat and facet', () => { + const spec: any = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28 }, + { x: 2, y: 55 }, + ], + }, + repeat: ['column1', 'column2'], + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Repeat and facet specifications are not yet supported'), + ); + }); + }); + + describe('Color Scheme Support', () => { + test('Should use category10 color scheme for multi-series line chart', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28, category: 'A' }, + { x: 2, y: 55, category: 'A' }, + { x: 1, y: 35, category: 'B' }, + { x: 2, y: 60, category: 'B' }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { + field: 'category', + type: 'nominal', + scale: { scheme: 'category10' }, + }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result.data.lineChartData).toHaveLength(2); + // Colors should be from category10 -> Fluent mapping + expect(result.data.lineChartData![0].color).toBeTruthy(); + expect(result.data.lineChartData![1].color).toBeTruthy(); + }); + + test('Should use custom color range when provided', () => { + const customColors = ['#ff0000', '#00ff00', '#0000ff']; + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + { category: 'C', value: 43 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + color: { + field: 'category', + type: 'nominal', + scale: { range: customColors }, + }, + }, + }; + + const result = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + + expect(result.data).toHaveLength(3); + // Should use custom colors from range + expect(result.data![0].color).toBe(customColors[0]); + expect(result.data![1].color).toBe(customColors[1]); + expect(result.data![2].color).toBe(customColors[2]); + }); + + test('Should prioritize custom range over scheme', () => { + const customColors = ['#ff0000', '#00ff00']; + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 28, category: 'A' }, + { x: 2, y: 55, category: 'A' }, + { x: 1, y: 35, category: 'B' }, + { x: 2, y: 60, category: 'B' }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { + field: 'category', + type: 'nominal', + scale: { + scheme: 'category10', + range: customColors, // Range should take priority + }, + }, + }, + }; + + const result = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(result.data.lineChartData).toHaveLength(2); + // Should use custom range, not category10 + expect(result.data.lineChartData![0].color).toBe(customColors[0]); + expect(result.data.lineChartData![1].color).toBe(customColors[1]); + }); + }); + }); + + describe('ColorMap - Persistent Colors', () => { + test('Should maintain consistent colors across re-renders using colorMap', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { date: '2023-01-01', value: 10, series: 'A' }, + { date: '2023-01-02', value: 20, series: 'A' }, + { date: '2023-01-01', value: 15, series: 'B' }, + { date: '2023-01-02', value: 25, series: 'B' }, + ], + }, + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'value', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + }; + + // First render - colorMap is empty + const testColorMap = new Map(); + const props1 = transformVegaLiteToLineChartProps(spec, { current: testColorMap }, false); + + // Verify colors are assigned + expect(props1.data?.lineChartData).toHaveLength(2); + const seriesAColor1 = props1.data?.lineChartData?.find(s => s.legend === 'A')?.color; + const seriesBColor1 = props1.data?.lineChartData?.find(s => s.legend === 'B')?.color; + expect(seriesAColor1).toBeDefined(); + expect(seriesBColor1).toBeDefined(); + + // Verify colorMap is populated + expect(testColorMap.size).toBe(2); + expect(testColorMap.get('A')).toBe(seriesAColor1); + expect(testColorMap.get('B')).toBe(seriesBColor1); + + // Second render with same colorMap - colors should be identical + const props2 = transformVegaLiteToLineChartProps(spec, { current: testColorMap }, false); + const seriesAColor2 = props2.data?.lineChartData?.find(s => s.legend === 'A')?.color; + const seriesBColor2 = props2.data?.lineChartData?.find(s => s.legend === 'B')?.color; + + expect(seriesAColor2).toBe(seriesAColor1); + expect(seriesBColor2).toBe(seriesBColor1); + }); + + test('Should assign colors in order when series are added', () => { + const testColorMap = new Map(); + + // First spec with series A + const spec1: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 10, series: 'A' }, + { x: 2, y: 20, series: 'A' }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + }; + + const props1 = transformVegaLiteToLineChartProps(spec1, { current: testColorMap }, false); + const colorA = props1.data?.lineChartData?.find(s => s.legend === 'A')?.color; + + // Second spec adds series B - should get next color + const spec2: VegaLiteSpec = { + ...spec1, + data: { + values: [ + ...spec1.data!.values!, + { x: 1, y: 15, series: 'B' }, + { x: 2, y: 25, series: 'B' }, + ], + }, + }; + + const props2 = transformVegaLiteToLineChartProps(spec2, { current: testColorMap }, false); + const colorA2 = props2.data?.lineChartData?.find(s => s.legend === 'A')?.color; + const colorB = props2.data?.lineChartData?.find(s => s.legend === 'B')?.color; + + // Series A should keep same color + expect(colorA2).toBe(colorA); + // Series B should have different color + expect(colorB).not.toBe(colorA); + // colorMap should have both + expect(testColorMap.size).toBe(2); + }); + }); + + describe('StrokeDash Support', () => { + test('Should apply strokeDash pattern to line chart', () => { + const spec: VegaLiteSpec = { + mark: { + type: 'line', + strokeDash: [5, 5], // Dashed line + strokeWidth: 3, + }, + data: { + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 15 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + expect(props.data?.lineChartData).toHaveLength(1); + const series = props.data?.lineChartData?.[0]; + expect(series?.lineOptions).toBeDefined(); + expect(series?.lineOptions?.strokeDasharray).toBe('5 5'); + expect(series?.lineOptions?.strokeWidth).toBe(3); + }); + + test('Should apply dotted line pattern', () => { + const spec: VegaLiteSpec = { + mark: { + type: 'line', + strokeDash: [2, 2], // Dotted line + }, + data: { + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + const series = props.data?.lineChartData?.[0]; + expect(series?.lineOptions?.strokeDasharray).toBe('2 2'); + }); + + test('Should apply custom dash pattern', () => { + const spec: VegaLiteSpec = { + mark: { + type: 'line', + strokeDash: [10, 5, 2, 5], // Custom pattern + }, + data: { + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + const series = props.data?.lineChartData?.[0]; + expect(series?.lineOptions?.strokeDasharray).toBe('10 5 2 5'); + }); + + test('Should work without strokeDash (solid line)', () => { + const spec: VegaLiteSpec = { + mark: { + type: 'line', + strokeWidth: 2, + }, + data: { + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + const series = props.data?.lineChartData?.[0]; + // Should have strokeWidth but no strokeDasharray + expect(series?.lineOptions?.strokeWidth).toBe(2); + expect(series?.lineOptions?.strokeDasharray).toBeUndefined(); + }); + }); + + describe('Ordinal X-Axis - Category Support', () => { + test('Should map ordinal x-axis values to sequential indices', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { quarter: 'Q1 2024', sales: 100 }, + { quarter: 'Q2 2024', sales: 150 }, + { quarter: 'Q3 2024', sales: 180 }, + { quarter: 'Q4 2024', sales: 200 }, + ], + }, + encoding: { + x: { field: 'quarter', type: 'ordinal' }, + y: { field: 'sales', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + // Should have 4 data points with sequential x-values (0, 1, 2, 3) + const series = props.data?.lineChartData?.[0]; + expect(series?.data).toHaveLength(4); + expect(series?.data?.[0].x).toBe(0); + expect(series?.data?.[1].x).toBe(1); + expect(series?.data?.[2].x).toBe(2); + expect(series?.data?.[3].x).toBe(3); + + // Y values should be preserved + expect(series?.data?.[0].y).toBe(100); + expect(series?.data?.[1].y).toBe(150); + expect(series?.data?.[2].y).toBe(180); + expect(series?.data?.[3].y).toBe(200); + + // tickValues should contain the ordinal labels + expect(props.tickValues).toEqual(['Q1 2024', 'Q2 2024', 'Q3 2024', 'Q4 2024']); + }); + + test('Should handle multi-series ordinal data consistently', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { quarter: 'Q1 2024', department: 'Sales', performance: 85 }, + { quarter: 'Q1 2024', department: 'Marketing', performance: 70 }, + { quarter: 'Q2 2024', department: 'Sales', performance: 90 }, + { quarter: 'Q2 2024', department: 'Marketing', performance: 75 }, + { quarter: 'Q3 2024', department: 'Sales', performance: 95 }, + { quarter: 'Q3 2024', department: 'Marketing', performance: 80 }, + ], + }, + encoding: { + x: { field: 'quarter', type: 'ordinal' }, + y: { field: 'performance', type: 'quantitative' }, + color: { field: 'department', type: 'nominal' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + // Should have 2 series + expect(props.data?.lineChartData).toHaveLength(2); + + // Both series should have same x-values (0, 1, 2) + const salesSeries = props.data?.lineChartData?.find(s => s.legend === 'Sales'); + const marketingSeries = props.data?.lineChartData?.find(s => s.legend === 'Marketing'); + + expect(salesSeries?.data).toHaveLength(3); + expect(marketingSeries?.data).toHaveLength(3); + + expect(salesSeries?.data?.[0].x).toBe(0); + expect(salesSeries?.data?.[1].x).toBe(1); + expect(salesSeries?.data?.[2].x).toBe(2); + + expect(marketingSeries?.data?.[0].x).toBe(0); + expect(marketingSeries?.data?.[1].x).toBe(1); + expect(marketingSeries?.data?.[2].x).toBe(2); + + // tickValues should contain ordinal labels + expect(props.tickValues).toEqual(['Q1 2024', 'Q2 2024', 'Q3 2024']); + }); + + test('Should work with nominal x-axis type (synonym for ordinal)', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { category: 'Low', value: 10 }, + { category: 'Medium', value: 20 }, + { category: 'High', value: 30 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + const series = props.data?.lineChartData?.[0]; + expect(series?.data).toHaveLength(3); + expect(series?.data?.[0].x).toBe(0); + expect(series?.data?.[1].x).toBe(1); + expect(series?.data?.[2].x).toBe(2); + expect(props.tickValues).toEqual(['Low', 'Medium', 'High']); + }); + + test('Should handle quantitative x-axis without ordinal mapping', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 30 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToLineChartProps(spec, { current: colorMap }, false); + + const series = props.data?.lineChartData?.[0]; + // Should use original numeric x values + expect(series?.data?.[0].x).toBe(1); + expect(series?.data?.[1].x).toBe(2); + expect(series?.data?.[2].x).toBe(3); + // Should not have ordinal tickValues + expect(props.tickValues).toBeUndefined(); + }); + }); + + describe('Polar Charts - Theta and Radius Encoding', () => { + test('Should handle arc marks with theta and radius as polar area charts', () => { + const spec: VegaLiteSpec = { + mark: { type: 'arc', innerRadius: 0 }, + data: { + values: [ + { category: 'A', value: 28, radius: 10 }, + { category: 'B', value: 55, radius: 15 }, + { category: 'C', value: 43, radius: 12 }, + ], + }, + encoding: { + theta: { field: 'value', type: 'quantitative' }, + radius: { field: 'radius', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + }, + }; + + const props = transformVegaLiteToPolarChartProps(spec, { current: colorMap }, false); + + // Should create polar chart data + expect(props.data).toHaveLength(3); + + // Each series should be area polar (arc marks become area polar) + expect(props.data?.[0].type).toBe('areapolar'); + expect(props.data?.[1].type).toBe('areapolar'); + expect(props.data?.[2].type).toBe('areapolar'); + + // Check data structure + expect(props.data?.[0].legend).toBe('A'); + expect(props.data?.[0].data).toHaveLength(1); + expect(props.data?.[0].data?.[0].theta).toBe(28); + expect(props.data?.[0].data?.[0].r).toBe(10); + }); + + test('Should handle line marks with theta and radius', () => { + const spec: VegaLiteSpec = { + mark: 'line', + data: { + values: [ + { angle: 0, distance: 5 }, + { angle: 90, distance: 10 }, + { angle: 180, distance: 7 }, + { angle: 270, distance: 12 }, + ], + }, + encoding: { + theta: { field: 'angle', type: 'quantitative' }, + radius: { field: 'distance', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToPolarChartProps(spec, { current: colorMap }, false); + + // Should create single line polar series + expect(props.data).toHaveLength(1); + expect(props.data?.[0].type).toBe('linepolar'); + expect(props.data?.[0].data).toHaveLength(4); + + // Check data points + expect(props.data?.[0].data?.[0]).toEqual({ theta: 0, r: 5 }); + expect(props.data?.[0].data?.[1]).toEqual({ theta: 90, r: 10 }); + }); + + test('Should handle point marks with theta and radius as scatter polar', () => { + const spec: VegaLiteSpec = { + mark: 'point', + data: { + values: [ + { angle: 45, distance: 8, series: 'S1' }, + { angle: 135, distance: 12, series: 'S1' }, + { angle: 225, distance: 6, series: 'S2' }, + ], + }, + encoding: { + theta: { field: 'angle', type: 'quantitative' }, + radius: { field: 'distance', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + }; + + const props = transformVegaLiteToPolarChartProps(spec, { current: colorMap }, false); + + // Should create two scatter polar series + expect(props.data).toHaveLength(2); + expect(props.data?.[0].type).toBe('scatterpolar'); + expect(props.data?.[1].type).toBe('scatterpolar'); + + const s1Series = props.data?.find(s => s.legend === 'S1'); + const s2Series = props.data?.find(s => s.legend === 'S2'); + + expect(s1Series?.data).toHaveLength(2); + expect(s2Series?.data).toHaveLength(1); + }); + + test('Should handle categorical theta encoding in polar charts', () => { + const spec: VegaLiteSpec = { + mark: 'area', + data: { + values: [ + { direction: 'North', strength: 8 }, + { direction: 'East', strength: 12 }, + { direction: 'South', strength: 6 }, + { direction: 'West', strength: 10 }, + ], + }, + encoding: { + theta: { field: 'direction', type: 'nominal' }, + radius: { field: 'strength', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToPolarChartProps(spec, { current: colorMap }, false); + + // Should create area polar chart + expect(props.data).toHaveLength(1); + expect(props.data?.[0].type).toBe('areapolar'); + + // Categorical theta values should be preserved as strings + expect(props.data?.[0].data?.[0].theta).toBe('North'); + expect(props.data?.[0].data?.[1].theta).toBe('East'); + + // Should have category order for angular axis + expect(props.angularAxis?.categoryOrder).toEqual(['North', 'East', 'South', 'West']); + }); + }); + + describe('Aggregate Bar Charts', () => { + test('Should handle count aggregation for vertical bars', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A' }, + { category: 'A' }, + { category: 'B' }, + { category: 'A' }, + { category: 'C' }, + { category: 'B' }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { aggregate: 'count', type: 'quantitative', title: 'Count' }, + }, + }; + + const props = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + + // Should aggregate counts per category + expect(props.data).toHaveLength(3); + + // Find data points by category + const pointA = props.data.find(d => d.x === 'A'); + const pointB = props.data.find(d => d.x === 'B'); + const pointC = props.data.find(d => d.x === 'C'); + + expect(pointA?.y).toBe(3); // 'A' appears 3 times + expect(pointB?.y).toBe(2); // 'B' appears 2 times + expect(pointC?.y).toBe(1); // 'C' appears 1 time + + // Check title + expect(props.yAxisTitle).toBe('Count'); + }); + + test('Should handle sum aggregation for vertical bars', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', value: 10 }, + { category: 'A', value: 20 }, + { category: 'B', value: 15 }, + { category: 'A', value: 5 }, + { category: 'C', value: 30 }, + { category: 'B', value: 25 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', aggregate: 'sum', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + + // Should aggregate sums per category + expect(props.data).toHaveLength(3); + + const pointA = props.data.find(d => d.x === 'A'); + const pointB = props.data.find(d => d.x === 'B'); + const pointC = props.data.find(d => d.x === 'C'); + + expect(pointA?.y).toBe(35); // 10 + 20 + 5 + expect(pointB?.y).toBe(40); // 15 + 25 + expect(pointC?.y).toBe(30); // 30 + }); + + test('Should handle mean aggregation for vertical bars', () => { + const spec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', value: 10 }, + { category: 'A', value: 20 }, + { category: 'B', value: 15 }, + { category: 'A', value: 30 }, + { category: 'B', value: 25 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', aggregate: 'mean', type: 'quantitative' }, + }, + }; + + const props = transformVegaLiteToVerticalBarChartProps(spec, { current: colorMap }, false); + + expect(props.data).toHaveLength(2); + + const pointA = props.data.find(d => d.x === 'A'); + const pointB = props.data.find(d => d.x === 'B'); + + expect(pointA?.y).toBe(20); // (10 + 20 + 30) / 3 + expect(pointB?.y).toBe(20); // (15 + 25) / 2 + }); + }); + +}); diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteTypes.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteTypes.ts new file mode 100644 index 00000000000000..e0a85f1f245f74 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteTypes.ts @@ -0,0 +1,769 @@ +/** + * Vega-Lite TypeScript interfaces for declarative chart specifications. + * This is a minimal subset focused on line/point charts with basic encodings. + * + * RECOMMENDED: For full type coverage, install the official vega-lite package: + * ``` + * npm install vega-lite + * ``` + * Then import `TopLevelSpec` from 'vega-lite' for complete schema support. + * + * The types provided here are a lightweight alternative that covers common use cases + * without requiring the full vega-lite dependency (~5.8MB unpacked). + * + * Full Vega-Lite spec: https://vega.github.io/vega-lite/docs/ + * + * TODO: Add support for: + * - Transform operations (filter, aggregate, calculate, etc.) + * - Remote data sources (url, named datasets) + * - Facet and concatenation for multi-view layouts + * - Selection interactions + * - Additional mark types (bar, area, etc.) + * - Conditional encodings + * - Tooltip customization + */ + +/** + * Vega-Lite data type for field encodings + */ +export type VegaLiteType = 'quantitative' | 'temporal' | 'ordinal' | 'nominal' | 'geojson'; + +/** + * Vega-Lite mark types + */ +export type VegaLiteMark = 'line' | 'point' | 'circle' | 'square' | 'bar' | 'area' | 'rect' | 'rule' | 'text'; + +/** + * Vega-Lite scale type + */ +export type VegaLiteScaleType = + | 'linear' + | 'log' + | 'pow' + | 'sqrt' + | 'symlog' + | 'time' + | 'utc' + | 'ordinal' + | 'band' + | 'point'; + +/** + * Vega-Lite interpolation method + */ +export type VegaLiteInterpolate = + | 'linear' + | 'linear-closed' + | 'step' + | 'step-before' + | 'step-after' + | 'basis' + | 'cardinal' + | 'monotone' + | 'natural'; + +/** + * Vega-Lite axis configuration + */ +export interface VegaLiteAxis { + /** + * Axis title + */ + title?: string | null; + + /** + * Format string for axis tick labels + * Uses d3-format for quantitative and d3-time-format for temporal + */ + format?: string; + + /** + * Tick values to display + */ + values?: number[] | string[]; + + /** + * Number of ticks + */ + tickCount?: number; + + /** + * Grid visibility + */ + grid?: boolean; +} + +/** + * Vega-Lite scale configuration + */ +export interface VegaLiteScale { + /** + * Scale type + */ + type?: VegaLiteScaleType; + + /** + * Domain values [min, max] + */ + domain?: [number | string, number | string]; + + /** + * Range values [min, max] + */ + range?: [number | string, number | string] | string[]; + + /** + * Color scheme name (e.g., 'category10', 'tableau10') + */ + scheme?: string; +} + +/** + * Vega-Lite legend configuration + */ +export interface VegaLiteLegend { + /** + * Legend title + */ + title?: string | null; + + /** + * Hide the legend + */ + disable?: boolean; +} + +/** + * Vega-Lite sort specification + */ +export type VegaLiteSort = + | 'ascending' + | 'descending' + | null + | { + field?: string; + op?: 'count' | 'sum' | 'mean' | 'average' | 'median' | 'min' | 'max'; + order?: 'ascending' | 'descending'; + } + | string[]; + +/** + * Vega-Lite binning configuration + */ +export interface VegaLiteBin { + /** + * Maximum number of bins + */ + maxbins?: number; + + /** + * Exact step size between bins + */ + step?: number; + + /** + * Extent [min, max] for binning + */ + extent?: [number, number]; + + /** + * Base for nice bin values (e.g., 10 for powers of 10) + */ + base?: number; + + /** + * Whether to include the boundary in bins + */ + anchor?: number; +} + +/** + * Vega-Lite position encoding channel (x or y) + */ +export interface VegaLitePositionEncoding { + /** + * Field name in data + */ + field?: string; + + /** + * Data type + */ + type?: VegaLiteType; + + /** + * Axis configuration + */ + axis?: VegaLiteAxis | null; + + /** + * Constant value for encoding (for reference lines and annotations) + */ + value?: number | string | Date; + + /** + * Datum value for encoding (alternative to value) + */ + datum?: number | string | Date; + + /** + * Scale configuration + */ + scale?: VegaLiteScale | null; + + /** + * Sort order for categorical axes + * Supports: 'ascending', 'descending', null, array of values, or object with field/op/order + */ + sort?: VegaLiteSort; + + /** + * Binning configuration for histograms + * Set to true for default binning or provide custom bin parameters + */ + bin?: boolean | VegaLiteBin; + + /** + * Stack configuration for area/bar charts + * - null: disable stacking + * - 'zero': stack from zero baseline (default for area charts) + * - 'center': center stack + * - 'normalize': normalize to 100% + */ + stack?: null | 'zero' | 'center' | 'normalize'; + + /** + * Aggregate function + */ + aggregate?: 'count' | 'sum' | 'mean' | 'average' | 'median' | 'min' | 'max'; + + /** + * Axis title (shorthand alternative to axis.title) + */ + title?: string; +} + +/** + * Vega-Lite color encoding channel + */ +export interface VegaLiteColorEncoding { + /** + * Field name for color differentiation + */ + field?: string; + + /** + * Data type + */ + type?: VegaLiteType; + + /** + * Legend configuration + */ + legend?: VegaLiteLegend | null; + + /** + * Scale configuration + */ + scale?: VegaLiteScale | null; + + /** + * Fixed color value + */ + value?: string; +} + +/** + * Vega-Lite size encoding channel + */ +export interface VegaLiteSizeEncoding { + /** + * Field name for size encoding + */ + field?: string; + + /** + * Data type + */ + type?: VegaLiteType; + + /** + * Fixed size value + */ + value?: number; +} + +/** + * Vega-Lite shape encoding channel + */ +export interface VegaLiteShapeEncoding { + /** + * Field name for shape encoding + */ + field?: string; + + /** + * Data type + */ + type?: VegaLiteType; + + /** + * Fixed shape value + */ + value?: string; +} + +/** + * Vega-Lite theta encoding channel for pie/donut charts and polar coordinates + */ +export interface VegaLiteThetaEncoding { + /** + * Field name + */ + field?: string; + + /** + * Data type + */ + type?: VegaLiteType; + + /** + * Aggregate function + */ + aggregate?: 'count' | 'sum' | 'mean' | 'average' | 'median' | 'min' | 'max'; + + /** + * Axis configuration for polar charts + */ + axis?: VegaLiteAxis | null; + + /** + * Scale configuration for polar charts + */ + scale?: VegaLiteScale | null; +} + +/** + * Vega-Lite radius encoding channel for polar charts + */ +export interface VegaLiteRadiusEncoding { + /** + * Field name + */ + field?: string; + + /** + * Data type + */ + type?: VegaLiteType; + + /** + * Aggregate function + */ + aggregate?: 'count' | 'sum' | 'mean' | 'average' | 'median' | 'min' | 'max'; + + /** + * Axis configuration + */ + axis?: VegaLiteAxis | null; + + /** + * Scale configuration + */ + scale?: VegaLiteScale | null; +} + +/** + * Vega-Lite text encoding channel + */ +export interface VegaLiteTextEncoding { + /** + * Field name + */ + field?: string; + + /** + * Data type + */ + type?: VegaLiteType; + + /** + * Fixed text value + */ + value?: string; +} + +/** + * Vega-Lite encoding channels + */ +export interface VegaLiteEncoding { + /** + * X-axis encoding + */ + x?: VegaLitePositionEncoding; + + /** + * Y-axis encoding + */ + y?: VegaLitePositionEncoding; + + /** + * Color encoding for series differentiation + */ + color?: VegaLiteColorEncoding; + + /** + * Size encoding for point marks + */ + size?: VegaLiteSizeEncoding; + + /** + * Shape encoding for point marks + */ + shape?: VegaLiteShapeEncoding; + + /** + * Theta encoding for pie/donut charts and polar coordinates + */ + theta?: VegaLiteThetaEncoding; + + /** + * Radius encoding for polar charts + */ + radius?: VegaLiteRadiusEncoding; + + /** + * X2 encoding for interval marks (rect, rule, bar with ranges) + */ + x2?: VegaLitePositionEncoding; + + /** + * Y2 encoding for interval marks (rect, rule, bar with ranges) + */ + y2?: VegaLitePositionEncoding; + + /** + * Text encoding for text marks + */ + text?: VegaLiteTextEncoding; +} + +/** + * Vega-Lite mark definition (can be string or object) + */ +export type VegaLiteMarkDef = + | VegaLiteMark + | { + type: VegaLiteMark; + /** + * Mark color + */ + color?: string; + /** + * Line interpolation method + */ + interpolate?: VegaLiteInterpolate; + /** + * Point marker visibility + */ + point?: boolean | { filled?: boolean; size?: number }; + /** + * Stroke width + */ + strokeWidth?: number; + /** + * Stroke dash pattern (e.g., [5, 5] for dashed, [2, 2] for dotted) + */ + strokeDash?: number[]; + /** + * Fill opacity + */ + fillOpacity?: number; + /** + * Stroke opacity + */ + strokeOpacity?: number; + /** + * Overall opacity + */ + opacity?: number; + /** + * Inner radius for arc/pie/donut marks (0-1 or pixel value) + */ + innerRadius?: number; + /** + * Outer radius for arc/pie marks (pixel value) + */ + outerRadius?: number; + }; + +/** + * Vega-Lite inline data + */ +export interface VegaLiteData { + /** + * Inline data values (array of objects) + */ + values?: Array>; + + /** + * URL to load data from + * TODO: Implement remote data loading + */ + url?: string; + + /** + * Named dataset reference + * TODO: Implement named dataset resolution + */ + name?: string; + + /** + * Data format specification + * TODO: Implement format parsing (csv, json, etc.) + */ + format?: { + type?: 'json' | 'csv' | 'tsv'; + parse?: Record; + }; +} + +/** + * Base Vega-Lite spec unit (single view) + */ +export interface VegaLiteUnitSpec { + /** + * Mark type + */ + mark: VegaLiteMarkDef; + + /** + * Encoding channels + */ + encoding?: VegaLiteEncoding; + + /** + * Data specification + */ + data?: VegaLiteData; + + /** + * Data transformations + * TODO: Implement transform pipeline + */ + transform?: Array>; +} + +/** + * Vega-Lite layer spec (multiple overlaid views) + */ +export interface VegaLiteLayerSpec { + /** + * Layer array + */ + layer: VegaLiteUnitSpec[]; + + /** + * Shared data across layers + */ + data?: VegaLiteData; + + /** + * Shared encoding across layers + */ + encoding?: VegaLiteEncoding; + + /** + * Data transformations + * TODO: Implement transform pipeline + */ + transform?: Array>; +} + +/** + * Vega-Lite title configuration with styling options + */ +export interface VegaLiteTitleParams { + /** + * Title text + */ + text: string; + + /** + * Subtitle text + */ + subtitle?: string; + + /** + * Font for the title + */ + font?: string; + + /** + * Font size for the title + */ + fontSize?: number; + + /** + * Font style for the title (e.g., 'normal', 'italic') + */ + fontStyle?: string; + + /** + * Font weight for the title (e.g., 'normal', 'bold', 100-900) + */ + fontWeight?: string | number; + + /** + * Color of the title text + */ + color?: string; + + /** + * Horizontal anchor position for the title + * - 'start': left-aligned + * - 'middle': centered + * - 'end': right-aligned + */ + anchor?: 'start' | 'middle' | 'end'; + + /** + * Vertical offset from the top of the chart + */ + offset?: number; + + /** + * Additional padding around the subtitle + */ + subtitlePadding?: number; +} + +/** + * Top-level Vega-Lite specification + */ +export interface VegaLiteSpec { + /** + * Schema version + */ + $schema?: string; + + /** + * Chart title - can be a string or a detailed title configuration + */ + title?: string | VegaLiteTitleParams; + + /** + * Chart description + */ + description?: string; + + /** + * Chart width + */ + width?: number | 'container'; + + /** + * Chart height + */ + height?: number | 'container'; + + /** + * Data specification (for single/layer specs) + */ + data?: VegaLiteData; + + /** + * Mark type (for single view) + */ + mark?: VegaLiteMarkDef; + + /** + * Encoding channels (for single view) + */ + encoding?: VegaLiteEncoding; + + /** + * Horizontal concatenation for multi-plot layouts + */ + hconcat?: VegaLiteSpec[]; + + /** + * Vertical concatenation for multi-plot layouts + */ + vconcat?: VegaLiteSpec[]; + + /** + * Layer specification + */ + layer?: VegaLiteUnitSpec[]; + + /** + * Data transformations + * TODO: Implement transform pipeline + */ + transform?: Array>; + + /** + * Background color + */ + background?: string; + + /** + * Padding configuration + */ + padding?: number | { top?: number; bottom?: number; left?: number; right?: number }; + + /** + * Auto-size configuration + */ + autosize?: string | { type?: string; contains?: string }; + + /** + * Configuration overrides + * TODO: Implement config resolution + */ + config?: Record; + + /** + * Interactive selection definitions + * TODO: Implement selection support + */ + selection?: Record; + + /** + * Facet specification for small multiples + * TODO: Implement facet support + */ + facet?: Record; + + /** + * Repeat specification for small multiples + * TODO: Implement repeat support + */ + repeat?: Record; + + /** + * Scale resolution configuration + * Controls whether scales are shared or independent across views + */ + resolve?: { + scale?: { + x?: 'shared' | 'independent'; + y?: 'shared' | 'independent'; + color?: 'shared' | 'independent'; + opacity?: 'shared' | 'independent'; + size?: 'shared' | 'independent'; + shape?: 'shared' | 'independent'; + }; + axis?: { + x?: 'shared' | 'independent'; + y?: 'shared' | 'independent'; + }; + legend?: { + color?: 'shared' | 'independent'; + opacity?: 'shared' | 'independent'; + size?: 'shared' | 'independent'; + shape?: 'shared' | 'independent'; + }; + }; +} diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/__snapshots__/VegaDeclarativeChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/__snapshots__/VegaDeclarativeChart.test.tsx.snap new file mode 100644 index 00000000000000..d234e52afa3be7 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/__snapshots__/VegaDeclarativeChart.test.tsx.snap @@ -0,0 +1,5464 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`VegaDeclarativeChart - Bar+Line Combo Rendering Bar + Line Combinations should render bar chart with single line overlay 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Bar+Line Combo Rendering Bar + Line Combinations should render bar+line with temporal x-axis 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Bar+Line Combo Rendering Bar + Line Combinations should render simple bar+line without color encoding 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Bar+Line Combo Rendering Bar + Line Combinations should render the actual line_bar_combo schema from schemas folder 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Chart Type Detection Snapshots for Chart Type Detection should match snapshot for donut chart with innerRadius 1`] = ` +
+
+
+
+
+ + + + + + + + + + +
+
+
+
+
+ + +
+
+
+
+
+
+
+`; + +exports[`VegaDeclarativeChart - Chart Type Detection Snapshots for Chart Type Detection should match snapshot for grouped bar chart 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`VegaDeclarativeChart - Chart Type Detection Snapshots for Chart Type Detection should match snapshot for heatmap chart 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Financial Ratios Heatmap should render financial ratios heatmap without errors 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Issue Fixes Issue 1: Heatmap Chart Not Rendering should match snapshot for heatmap 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Issue Fixes Issue 2: Line+Bar Combo (Now Supported!) should match snapshot for line+bar combo 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - More Heatmap Charts should render heatmap from actual air_quality_heatmap.json schema 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - More Heatmap Charts should render heatmap from actual attendance_heatmap.json schema 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - More Heatmap Charts should render heatmap with rect marks and quantitative color 1`] = ` +
+
+
+ +
+
+`; + +exports[`VegaDeclarativeChart - Scatter Charts should render scatter chart from actual bmi_scatter.json schema 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`VegaDeclarativeChart - Scatter Charts should render scatter chart with basic point encoding 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`VegaDeclarativeChart - Scatter Charts should render scatter chart with size encoding 1`] = ` +
+
+
+ +
+
+
+`; diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/__snapshots__/VegaLiteSchemaAdapterUT.test.tsx.snap b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/__snapshots__/VegaLiteSchemaAdapterUT.test.tsx.snap new file mode 100644 index 00000000000000..1353be28443393 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/__snapshots__/VegaLiteSchemaAdapterUT.test.tsx.snap @@ -0,0 +1,340 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`VegaLiteSchemaAdapter getVegaLiteLegendsProps Should generate legends for multi-series chart 1`] = ` +Object { + "canSelectMultipleLegends": true, + "centerLegends": true, + "enabledWrapLines": true, + "legends": Array [ + Object { + "color": "#637cef", + "title": "Alpha", + }, + Object { + "color": "#e3008c", + "title": "Beta", + }, + Object { + "color": "#2aa0a4", + "title": "Gamma", + }, + ], +} +`; + +exports[`VegaLiteSchemaAdapter getVegaLiteLegendsProps Should return empty legends when no color encoding 1`] = ` +Object { + "canSelectMultipleLegends": true, + "centerLegends": true, + "enabledWrapLines": true, + "legends": Array [], +} +`; + +exports[`VegaLiteSchemaAdapter getVegaLiteTitles Should extract chart and axis titles 1`] = ` +Object { + "chartTitle": "Sales Over Time", + "xAxisTitle": "Month", + "yAxisTitle": "Sales ($)", +} +`; + +exports[`VegaLiteSchemaAdapter getVegaLiteTitles Should handle object-form title 1`] = ` +Object { + "chartTitle": "Main Title", + "xAxisTitle": undefined, + "yAxisTitle": undefined, +} +`; + +exports[`VegaLiteSchemaAdapter getVegaLiteTitles Should return empty titles for minimal spec 1`] = ` +Object { + "chartTitle": undefined, + "xAxisTitle": undefined, + "yAxisTitle": undefined, +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should extract axis titles and formats 1`] = ` +Object { + "chartTitle": "X Axis", + "data": Object { + "chartTitle": "Chart with Formats", + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 1, + "y": 100, + }, + Object { + "x": 2, + "y": 200, + }, + ], + "hideNonActiveDots": true, + "legend": "default", + }, + ], + }, + "height": 400, + "hideLegend": false, + "tickFormat": ".0f", + "width": 800, + "yAxisTickCount": 5, + "yAxisTickFormat": ".2f", + "yAxisTitle": "Y Axis", +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should handle interpolation mapping 1`] = ` +Object { + "data": Object { + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 1, + "y": 28, + }, + Object { + "x": 2, + "y": 55, + }, + ], + "hideNonActiveDots": true, + "legend": "default", + "lineOptions": Object { + "curve": "linear", + }, + }, + ], + }, + "height": undefined, + "hideLegend": false, + "width": undefined, +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should handle y-axis domain/range 1`] = ` +Object { + "data": Object { + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 1, + "y": 50, + }, + Object { + "x": 2, + "y": 150, + }, + ], + "hideNonActiveDots": true, + "legend": "default", + }, + ], + }, + "height": undefined, + "hideLegend": false, + "width": undefined, + "yMaxValue": 200, + "yMinValue": 0, +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should hide legend when disabled 1`] = ` +Object { + "data": Object { + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 1, + "y": 28, + }, + ], + "hideNonActiveDots": true, + "legend": "A", + }, + Object { + "color": "#e3008c", + "data": Array [ + Object { + "x": 2, + "y": 55, + }, + ], + "hideNonActiveDots": true, + "legend": "B", + }, + ], + }, + "height": undefined, + "hideLegend": true, + "width": undefined, +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should transform basic line chart with quantitative axes 1`] = ` +Object { + "data": Object { + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 1, + "y": 28, + }, + Object { + "x": 2, + "y": 55, + }, + Object { + "x": 3, + "y": 43, + }, + Object { + "x": 4, + "y": 91, + }, + Object { + "x": 5, + "y": 81, + }, + ], + "hideNonActiveDots": true, + "legend": "default", + }, + ], + }, + "height": undefined, + "hideLegend": false, + "width": undefined, +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should transform layered spec with line and point marks 1`] = ` +Object { + "data": Object { + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 1, + "y": 28, + }, + Object { + "x": 2, + "y": 55, + }, + Object { + "x": 3, + "y": 43, + }, + ], + "hideNonActiveDots": false, + "legend": "default", + }, + ], + }, + "height": undefined, + "hideLegend": false, + "width": undefined, +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should transform line chart with temporal x-axis 1`] = ` +Object { + "chartTitle": "Date", + "data": Object { + "chartTitle": "Time Series Chart", + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 2024-01-01T00:00:00.000Z, + "y": 100, + }, + Object { + "x": 2024-02-01T00:00:00.000Z, + "y": 150, + }, + Object { + "x": 2024-03-01T00:00:00.000Z, + "y": 120, + }, + Object { + "x": 2024-04-01T00:00:00.000Z, + "y": 180, + }, + ], + "hideNonActiveDots": true, + "legend": "default", + }, + ], + }, + "height": undefined, + "hideLegend": false, + "width": undefined, + "yAxisTitle": "Value", +} +`; + +exports[`VegaLiteSchemaAdapter transformVegaLiteToLineChartProps Should transform multi-series chart with color encoding 1`] = ` +Object { + "data": Object { + "lineChartData": Array [ + Object { + "color": "#637cef", + "data": Array [ + Object { + "x": 1, + "y": 28, + }, + Object { + "x": 2, + "y": 55, + }, + Object { + "x": 3, + "y": 43, + }, + ], + "hideNonActiveDots": true, + "legend": "A", + }, + Object { + "color": "#e3008c", + "data": Array [ + Object { + "x": 1, + "y": 35, + }, + Object { + "x": 2, + "y": 60, + }, + Object { + "x": 3, + "y": 50, + }, + ], + "hideNonActiveDots": true, + "legend": "B", + }, + ], + }, + "height": undefined, + "hideLegend": false, + "width": undefined, +} +`; diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/index.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/index.ts new file mode 100644 index 00000000000000..04d6eaa3e554e3 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/index.ts @@ -0,0 +1 @@ +export * from './VegaDeclarativeChart'; diff --git a/packages/charts/react-charts/library/src/index.ts b/packages/charts/react-charts/library/src/index.ts index 3ce48ceebc7098..2eee6b7cd5c59f 100644 --- a/packages/charts/react-charts/library/src/index.ts +++ b/packages/charts/react-charts/library/src/index.ts @@ -14,6 +14,7 @@ export * from './utilities/colors'; export * from './Popover'; export * from './ResponsiveContainer'; export * from './DeclarativeChart'; +export * from './VegaDeclarativeChart'; export * from './AreaChart'; export * from './HorizontalBarChartWithAxis'; export * from './HeatMapChart'; diff --git a/packages/charts/react-charts/stories/E2E_SCREENSHOT_TESTING.md b/packages/charts/react-charts/stories/E2E_SCREENSHOT_TESTING.md new file mode 100644 index 00000000000000..0a55d97c237699 --- /dev/null +++ b/packages/charts/react-charts/stories/E2E_SCREENSHOT_TESTING.md @@ -0,0 +1,288 @@ +# E2E Screenshot Testing for VegaDeclarativeChart + +## Overview + +Playwright-based E2E tests that navigate the VegaDeclarativeChart storybook story, iterate through all chart schemas via the dropdown, capture screenshots, and visually validate that each chart renders correctly against its input schema. + +## Architecture + +``` +stories/ +├── playwright.config.ts # Playwright config (chromium, port 6006, express static server) +├── scripts/ +│ └── e2e.js # E2E runner script (handles Node version detection) +├── screenshots/ # Output directory for captured PNGs +│ ├── adCtrScatter.png +│ ├── ageDistributionBar.png +│ └── ... # One PNG per chart schema +├── src/ +│ └── VegaDeclarativeChart/ +│ └── VegaDeclarativeChart.spec.ts # Playwright test spec +└── dist/ + └── storybook/ # Built storybook (generated by build step) +``` + +## Prerequisites + +- Node.js 18+ +- Playwright browsers installed: `npx playwright install chromium` +- Built storybook output in `dist/storybook/` + +## Setup + +### 1. Install Playwright browsers (one-time) + +```bash +npx playwright install chromium +``` + +### 2. Build storybook + +From the stories directory: + +```bash +cd packages/charts/react-charts/stories +npx storybook build -o ./dist/storybook +``` + +Or via nx from the repo root: + +```bash +npx nx run react-charts-stories:storybook --build +``` + +### 3. Run the tests + +```bash +cd packages/charts/react-charts/stories +npx playwright test +``` + +Or via the e2e script: + +```bash +node scripts/e2e.js +``` + +## How It Works + +### Test Flow + +1. **Storybook served**: Playwright config starts an express static server on port 6006 serving the built storybook from `dist/storybook/`. +2. **Story navigation**: Each test navigates to `http://localhost:6006/iframe.html?id=charts-vegadeclarativechart--default&viewMode=story`. +3. **Dropdown interaction**: The test locates the "Chart Type" Fluent UI Dropdown via `getByRole('combobox', { name: 'Chart Type' })`, clicks it, then selects the target chart option via `getByRole('option', { name: chartText })`. +4. **Render wait**: After selection, the test waits 2 seconds for chart animation/rendering to complete. +5. **Screenshot capture**: A full-page screenshot is saved to `screenshots/{chartKey}.png`. + +### Parallelization + +- Tests run with 8 workers by default (configurable in `playwright.config.ts`) +- Each test is independent (navigates to the story fresh) so they can run fully in parallel +- 25 charts complete in ~20 seconds + +### Dropdown Selector Details + +The Fluent UI `` inside a `` creates an accessible combobox. The test uses: + +```typescript +// Open dropdown +const chartDropdown = page.getByRole('combobox', { name: 'Chart Type' }); +await chartDropdown.click(); + +// Select option +const option = page.getByRole('option', { name: chartText, exact: true }); +await option.click(); +``` + +The `chartText` is derived from the schema key using the same logic as the story: + +```typescript +// "adCtrScatter" → "Ad Ctr Scatter" +const text = key + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +``` + +## Current Schema Coverage (25 Built-in) + +| Chart Key | Type | Category | +| ------------------------ | ----------------------- | ------------ | +| adCtrScatter | Scatter | Marketing | +| ageDistributionBar | Vertical Bar | Healthcare | +| airQualityHeatmap | Heatmap | Climate | +| apiResponseLine | Line | Technology | +| areaMultiSeriesNoStack | Area (multi, no stack) | Other | +| areaSingleTozeroy | Area (single) | Other | +| areaStackedTonexty | Stacked Area | Other | +| areachart | Area (multi) | Basic Charts | +| attendanceBar | Vertical Bar | Sports | +| attendanceHeatmap | Heatmap | Education | +| bandwidthStackedArea | Stacked Area | Technology | +| barchart | Horizontal Bar | Basic Charts | +| biodiversityGrouped | Grouped Bar | Climate | +| bmiScatter | Scatter | Healthcare | +| budgetActualGrouped | Grouped Bar | Financial | +| bugPriorityDonut | Donut | Technology | +| campaignPerformanceCombo | Combo (bar+line) | Marketing | +| cashflowCombo | Combo (bar+line) | Financial | +| categorySalesStacked | Stacked Bar | E-Commerce | +| channelDistributionDonut | Donut | Marketing | +| climateZonesScatter | Scatter | Climate | +| co2EmissionsArea | Area | Climate | +| codeCommitsCombo | Combo (bar+line) | Technology | +| conversionFunnel | Horizontal Bar (funnel) | E-Commerce | +| courseEnrollmentDonut | Donut | Education | + +## Visual Inspection Results (25 Built-in Schemas) + +**Run date**: 2026-02-08 +**Result**: 24/25 PASS, 1/25 CONCERN + +| # | Chart | Verdict | Notes | +| --- | ------------------------ | ------- | --------------------------------------------------------------------------- | +| 1 | adCtrScatter | PASS | 7 points, impressions vs CTR, sized by clicks | +| 2 | ageDistributionBar | PASS | 9 age groups, values match schema data | +| 3 | airQualityHeatmap | PASS | 4x3 grid, AQI values visible in cells | +| 4 | apiResponseLine | PASS | Temporal axis 08:00-17:00, response times correct | +| 5 | areaMultiSeriesNoStack | CONCERN | Areas compressed to right edge; legend/series correct but layout suboptimal | +| 6 | areaSingleTozeroy | PASS | Monthly revenue upward trend $12k-$38k | +| 7 | areaStackedTonexty | PASS | 3 stacked series, temporal axis Jan-Dec | +| 8 | areachart | PASS | 2 series A & B, temporal dates Jan 1-5 | +| 9 | attendanceBar | PASS | 6 games, bar values match (42.5k-52.5k) | +| 10 | attendanceHeatmap | PASS | 5x4 grid, attendance % visible in each cell | +| 11 | bandwidthStackedArea | PASS | Inbound/Outbound series, temporal axis | +| 12 | barchart | PASS | 5 horizontal bars A-E, values match | +| 13 | biodiversityGrouped | PASS | 3 regions x 3 categories, grouped correctly | +| 14 | bmiScatter | PASS | 10 points, color-coded by BMI category | +| 15 | budgetActualGrouped | PASS | 4 departments, Budget vs Actual paired bars | +| 16 | bugPriorityDonut | PASS | 4 segments, donut with inner radius | +| 17 | campaignPerformanceCombo | PASS | Bars for spend, line for conversions | +| 18 | cashflowCombo | PASS | Green/red bars for positive/negative cashflow | +| 19 | categorySalesStacked | PASS | Q1/Q2/Q3 stacked, totals match | +| 20 | channelDistributionDonut | PASS | 6 segments, donut with pad angle | +| 21 | climateZonesScatter | PASS | 7 zones, temp vs precipitation | +| 22 | co2EmissionsArea | PASS | Years 1990-2023, upward trend with 2020 dip | +| 23 | codeCommitsCombo | PASS | Coral bars + navy line, values match | +| 24 | conversionFunnel | PASS | 6 stages, decreasing 50k→1.6k | +| 25 | courseEnrollmentDonut | PASS | 6 courses, donut with title | + +## 1000+ Schema Evaluation (fluentui-charting-contrib) + +### Test Setup + +**Source**: 1,056 Vega-Lite schema files from `fluentui-charting-contrib/vega_data/` +**Spec file**: `VegaDeclarativeChart.e2e.spec.ts` +**Screenshot output**: `screenshots/vega_data/{schemaName}.png` + +The test reads local JSON schema files, injects each into the story's textarea editor, waits for rendering, and captures a full-page screenshot. Schemas are processed in batches of 50, parallelized across 8 Playwright workers. + +### How to Run + +```bash +cd packages/charts/react-charts/stories + +# Run only the 1000+ schema tests +npx playwright test VegaDeclarativeChart.e2e.spec.ts + +# Run only the 25 built-in dropdown tests +npx playwright test VegaDeclarativeChart.spec.ts +``` + +### Performance + +| Metric | Value | +| ------------------ | ------------------- | +| Total schemas | 1,056 | +| Batch size | 50 schemas per test | +| Total batches | 22 | +| Workers | 8 (parallel) | +| Total duration | **6.4 minutes** | +| Per-schema time | ~0.36 seconds | +| Screenshot storage | ~116 MB total | + +### Evaluation Results + +**Run date**: 2026-02-08 +**Detection method**: Pixel-level analysis of each PNG using `pngjs`. Error screenshots display a red error boundary box with a distinctive pattern (2 horizontal red border rows + vertical red border columns starting at y=336). + +| Category | Count | Percentage | +| --------------------- | --------- | ---------- | +| **Successful charts** | **536** | **50.8%** | +| **Rendering errors** | **510** | **48.3%** | +| **Blank (no chart)** | **10** | **0.9%** | +| **Total** | **1,056** | **100%** | + +### Error Categories + +Three distinct error types were identified from visual inspection: + +#### 1. VegaLiteSchemaAdapter transformation errors (~majority of errors) + +- **"Field marked as quantitative but contains non-numeric values"** — Schemas use `type: "quantitative"` on fields containing string data (e.g., data_400 histogram with `bin: true` on dog/cat values, data_800 scatter with non-numeric x field) +- **"No numeric values found for histogram binning"** — Histogram schemas with categorical data (e.g., data_710 automobile types) +- **Root cause**: The VegaLiteSchemaAdapter validates field types strictly and rejects schemas where the declared encoding type doesn't match the actual data + +#### 2. Component runtime errors (~subset of errors) + +- **"Cannot read properties of undefined (reading '0')"** — Heatmap schemas where the adapter produces incomplete data (e.g., data_500) +- **"Cannot read properties of undefined (reading 'chartData')"** — Bar/Gantt schemas where transformation produces null props (e.g., data_900) +- **Root cause**: Edge cases in the component where the adapter returns partial/null results that the rendering code doesn't guard against + +#### 3. Blank renders (10 schemas) + +- **data_886 through data_892** (7 files) — Polar/radial chart schemas using `theta` and `radius` encodings with `mark: "arc"` and `innerRadius: 0` +- **data_1013 through data_1015** (3 files) — Similar unsupported encoding patterns +- **Root cause**: The component silently produces no output for chart types it doesn't support (polar charts without donut semantics) + +### Sample Visual Inspection (20 schemas across full range) + +| Schema | Verdict | Notes | +| -------- | ------- | ------------------------------------------------------------- | +| data_001 | PASS | Area chart, monthly sales 5k-7.2k, temporal axis | +| data_010 | PASS | Area chart, weekly visits 500-3000 | +| data_050 | PASS | Bar chart, 5 subjects, scores 75-87 | +| data_100 | PASS | Bar chart, 5 subjects, scores 78-88 | +| data_150 | PASS | Heatmap, 3x3 supplier efficiency ratios | +| data_200 | CONCERN | Line+point combo, chart rendered but compressed to single bar | +| data_250 | PASS | Scatter, employee task completion with size encoding | +| data_300 | PASS | Grouped bar, Japanese labels (月), 3 sectors x 12 months | +| data_400 | ERROR | Histogram with non-numeric bin values | +| data_440 | PASS | Grouped bar, fleet maintenance data | +| data_500 | ERROR | Heatmap, "Cannot read properties of undefined" | +| data_598 | PASS | Multi-series line chart, complex rendering | +| data_600 | PASS | Line chart, education enrollment trends, 3 series | +| data_710 | ERROR | Histogram, "No numeric values found for binning" | +| data_800 | ERROR | Scatter, "Field 'x' marked as quantitative but non-numeric" | +| data_886 | BLANK | Polar chart, theta/radius encoding, no output | +| data_900 | ERROR | Bar/Gantt, "Cannot read properties of undefined" | +| data_999 | PASS | Bar chart, energy sales by customer segment | + +### Key Takeaways + +1. **50.8% success rate** across 1,056 diverse Vega-Lite schemas — the component handles standard chart types well +2. **Histogram binning** on non-numeric data is the single largest failure category +3. **Polar/radial charts** with theta+radius encoding silently render blank +4. **International characters** (Japanese, etc.) render correctly in labels and axes +5. **Complex multi-series charts** (layered line+point combos) render but may have layout issues +6. The error boundary correctly catches and displays all failures — no uncaught crashes + +## Configuration Reference + +### playwright.config.ts + +| Setting | Value | Notes | +| ------------- | ------------------------------------ | ----------------------------------------- | +| baseURL | `http://localhost:6006/iframe.html` | Storybook iframe | +| viewport | 1280x720 | Desktop Chrome | +| timeout | 600s (local) / 10s (CI) | Per-test timeout (high for batched tests) | +| retries | 3 (dropdown tests) / 0 (batch tests) | Batch tests handle errors internally | +| fullyParallel | true (local) / false (CI) | Parallel execution | +| webServer | express static on port 6006 | Serves built storybook | + +### Screenshot Output + +- **Format**: PNG +- **Resolution**: 1280x720 viewport (full page capture) +- **Built-in schemas**: `packages/charts/react-charts/stories/screenshots/{schemaKey}.png` +- **External schemas**: `packages/charts/react-charts/stories/screenshots/vega_data/{schemaName}.png` diff --git a/packages/charts/react-charts/stories/package.json b/packages/charts/react-charts/stories/package.json index 6e280d82ce9f3e..edbafb3153e936 100644 --- a/packages/charts/react-charts/stories/package.json +++ b/packages/charts/react-charts/stories/package.json @@ -2,6 +2,9 @@ "name": "@fluentui/react-charts-stories", "version": "0.0.0", "private": true, + "scripts": { + "e2e": "node ./scripts/e2e.js" + }, "devDependencies": { "d3-format": "^3.0.0", "d3-time-format": "^3.0.0" diff --git a/packages/charts/react-charts/stories/playwright.config.ts b/packages/charts/react-charts/stories/playwright.config.ts new file mode 100644 index 00000000000000..b05bfa9ba3a1f9 --- /dev/null +++ b/packages/charts/react-charts/stories/playwright.config.ts @@ -0,0 +1,31 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + reporter: 'list', + retries: 3, + fullyParallel: process.env.CI ? false : true, + timeout: process.env.CI ? 10000 : 600000, + use: { + baseURL: 'http://localhost:6006/iframe.html', + viewport: { + height: 720, + width: 1280, + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + testMatch: /.*\.spec\.ts$/, + }, + ], + webServer: { + // double-quotes are required for Windows + command: `node -e "import('express').then(({ default: e }) => e().use(e.static('./dist/storybook')).listen(6006))"`, + port: 6006, + reuseExistingServer: process.env.CI ? false : true, + }, +}; + +export default config; diff --git a/packages/charts/react-charts/stories/scripts/create-contact-sheets.mjs b/packages/charts/react-charts/stories/scripts/create-contact-sheets.mjs new file mode 100644 index 00000000000000..47de6923bd635f --- /dev/null +++ b/packages/charts/react-charts/stories/scripts/create-contact-sheets.mjs @@ -0,0 +1,69 @@ +import sharp from 'sharp'; +import fs from 'fs'; +import path from 'path'; + +const SCREENSHOTS_DIR = path.resolve('packages/charts/react-charts/stories/screenshots/vega_data'); +const OUTPUT_DIR = path.resolve('packages/charts/react-charts/stories/screenshots/contact_sheets'); + +const COLS = 5; +const ROWS = 5; +const PER_SHEET = COLS * ROWS; +const THUMB_W = 256; +const THUMB_H = 192; +const LABEL_H = 20; +const CELL_H = THUMB_H + LABEL_H; +const SHEET_W = COLS * THUMB_W; +const SHEET_H = ROWS * CELL_H; + +if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + +const files = fs.readdirSync(SCREENSHOTS_DIR) + .filter(f => f.endsWith('.png')) + .sort(); + +console.log(`Total screenshots: ${files.length}`); +console.log(`Contact sheets: ${Math.ceil(files.length / PER_SHEET)}`); + +for (let sheetIdx = 0; sheetIdx < Math.ceil(files.length / PER_SHEET); sheetIdx++) { + const batch = files.slice(sheetIdx * PER_SHEET, (sheetIdx + 1) * PER_SHEET); + const composites = []; + + for (let i = 0; i < batch.length; i++) { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = col * THUMB_W; + const y = row * CELL_H; + + // Resize screenshot to thumbnail + const thumb = await sharp(path.join(SCREENSHOTS_DIR, batch[i])) + .resize(THUMB_W, THUMB_H, { fit: 'fill' }) + .toBuffer(); + + composites.push({ input: thumb, left: x, top: y }); + + // Create label with schema name + const label = batch[i].replace('_vega.png', '').replace('data_', ''); + const svgLabel = Buffer.from(` + + ${label} + `); + const labelBuf = await sharp(svgLabel).png().toBuffer(); + composites.push({ input: labelBuf, left: x, top: y + THUMB_H }); + } + + const sheet = await sharp({ + create: { width: SHEET_W, height: SHEET_H, channels: 4, background: { r: 34, g: 34, b: 34, alpha: 1 } } + }) + .composite(composites) + .png() + .toBuffer(); + + const outPath = path.join(OUTPUT_DIR, `sheet_${String(sheetIdx + 1).padStart(2, '0')}.png`); + fs.writeFileSync(outPath, sheet); + + const startNum = sheetIdx * PER_SHEET + 1; + const endNum = Math.min((sheetIdx + 1) * PER_SHEET, files.length); + console.log(`Created ${outPath} (schemas ${startNum}-${endNum})`); +} + +console.log('Done!'); diff --git a/packages/charts/react-charts/stories/scripts/e2e.js b/packages/charts/react-charts/stories/scripts/e2e.js new file mode 100644 index 00000000000000..1c98f45a599b1a --- /dev/null +++ b/packages/charts/react-charts/stories/scripts/e2e.js @@ -0,0 +1,18 @@ +/* eslint-env node */ +import { execSync } from 'node:child_process'; + +try { + const [major, minor, _patch] = process.versions.node.split('.').map(n => parseInt(n, 10)); + let env = {}; + if (major > 22 || (major === 22 && minor >= 18)) { + env = { NODE_OPTIONS: '--no-experimental-strip-types' }; + } + // Run the tests + execSync(`playwright test`, { + stdio: 'inherit', + env: { ...env, ...process.env }, + }); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/packages/charts/react-charts/stories/src/DeclarativeChart/docs/DeclarativeChartOverview.md b/packages/charts/react-charts/stories/src/DeclarativeChart/docs/DeclarativeChartOverview.md index 852b5767a4fd70..2c34e1e9cbf9ea 100644 --- a/packages/charts/react-charts/stories/src/DeclarativeChart/docs/DeclarativeChartOverview.md +++ b/packages/charts/react-charts/stories/src/DeclarativeChart/docs/DeclarativeChartOverview.md @@ -1 +1,224 @@ DeclarativeChart enables developers to render interactive chart visualizations using Plotly's widely adopted JSON-based schema while ensuring a consistent Fluent UI design language. + +For Vega-Lite specifications, use the **VegaDeclarativeChart** component instead. + +## Components + +### DeclarativeChart + +Renders charts from **Plotly JSON schemas**. Supports all Fluent chart types with full Plotly compatibility. + +### VegaDeclarativeChart + +Renders charts from **Vega-Lite specifications**. Supports the following chart types: + +- **Line Charts** - `mark: 'line'` or `mark: 'point'` +- **Area Charts** - `mark: 'area'` +- **Scatter Charts** - `mark: 'point'`, `mark: 'circle'`, or `mark: 'square'` +- **Vertical Bar Charts** - `mark: 'bar'` with nominal/ordinal x-axis +- **Stacked Bar Charts** - `mark: 'bar'` with color encoding (stacks by default) +- **Grouped Bar Charts** - Available via explicit configuration +- **Horizontal Bar Charts** - `mark: 'bar'` with nominal/ordinal y-axis +- **Donut/Pie Charts** - `mark: 'arc'` with theta encoding +- **Heatmaps** - `mark: 'rect'` with x, y, and color encodings + +> **Note:** Sankey, Funnel, Gantt, and Gauge charts are not standard Vega-Lite marks. These specialized visualizations would require custom extensions or alternative approaches. + +#### Examples + +**Line Chart:** + +```tsx +import { VegaDeclarativeChart } from '@fluentui/react-charts'; + +const lineSpec = { + mark: 'line', + data: { + values: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, +}; + +; +``` + +**Area Chart:** + +```tsx +const areaSpec = { + mark: 'area', + data: { + values: [ + { date: '2023-01', value: 100 }, + { date: '2023-02', value: 150 }, + ], + }, + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'value', type: 'quantitative' }, + }, +}; + +; +``` + +**Scatter Chart:** + +```tsx +const scatterSpec = { + mark: 'point', + data: { + values: [ + { x: 10, y: 20, size: 100 }, + { x: 15, y: 30, size: 200 }, + ], + }, + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + size: { field: 'size', type: 'quantitative' }, + }, +}; + +; +``` + +**Vertical Bar Chart:** + +```tsx +const barSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', amount: 28 }, + { category: 'B', amount: 55 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'amount', type: 'quantitative' }, + }, +}; + +; +``` + +**Stacked Bar Chart:** + +```tsx +const stackedSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', group: 'G1', amount: 28 }, + { category: 'A', group: 'G2', amount: 15 }, + ], + }, + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'amount', type: 'quantitative' }, + color: { field: 'group', type: 'nominal' }, + }, +}; + +; +``` + +**Horizontal Bar Chart:** + +```tsx +const hbarSpec = { + mark: 'bar', + data: { + values: [ + { category: 'A', amount: 28 }, + { category: 'B', amount: 55 }, + ], + }, + encoding: { + y: { field: 'category', type: 'nominal' }, + x: { field: 'amount', type: 'quantitative' }, + }, +}; + +; +``` + +**Donut Chart:** + +```tsx +const donutSpec = { + mark: 'arc', + data: { + values: [ + { category: 'A', value: 30 }, + { category: 'B', value: 70 }, + ], + }, + encoding: { + theta: { field: 'value', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + }, +}; + +; +``` + +**Heatmap:** + +```tsx +const heatmapSpec = { + mark: 'rect', + data: { + values: [ + { x: 'A', y: 'Mon', value: 28 }, + { x: 'B', y: 'Mon', value: 55 }, + { x: 'A', y: 'Tue', value: 43 }, + ], + }, + encoding: { + x: { field: 'x', type: 'nominal' }, + y: { field: 'y', type: 'nominal' }, + color: { field: 'value', type: 'quantitative' }, + }, +}; + +; +``` + +## Bundle Size Optimization + +Both components are **tree-shakable**: + +- Using only `DeclarativeChart`? Vega-Lite code is excluded from your bundle +- Using only `VegaDeclarativeChart`? Plotly adapter code is excluded from your bundle +- Import only what you need for optimal bundle size + +## Vega-Lite Type Definitions + +VegaDeclarativeChart accepts any valid Vega-Lite specification. For comprehensive TypeScript support, optionally install the official types: + +```bash +npm install vega-lite +``` + +Then use the official types: + +```typescript +import type { TopLevelSpec } from 'vega-lite'; +import { VegaDeclarativeChart } from '@fluentui/react-charts'; + +const spec: TopLevelSpec = { + // Full type checking and IntelliSense +}; + +; +``` + +The vega-lite package is marked as an **optional peer dependency**, so it won't be bundled unless you explicitly use its types. diff --git a/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.e2e.spec.ts b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.e2e.spec.ts new file mode 100644 index 00000000000000..6d88dcff16c3fd --- /dev/null +++ b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.e2e.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const storyUrl = '?id=charts-vegadeclarativechart--default&viewMode=story'; +const SCHEMAS_DIR = 'C:\\Users\\atisjai\\dev\\fluentui-charting-contrib\\vega_data'; +const screenshotsDir = path.resolve(__dirname, '../../screenshots/vega_data'); + +// Read all schema files at test definition time +const schemaFiles = fs + .readdirSync(SCHEMAS_DIR) + .filter(f => /^data_\d+_vega\.json$/.test(f)) + .sort(); + +// Split into batches of 50 for parallel execution across workers +const BATCH_SIZE = 50; +const batches: string[][] = []; +for (let i = 0; i < schemaFiles.length; i += BATCH_SIZE) { + batches.push(schemaFiles.slice(i, i + BATCH_SIZE)); +} + +// Ensure screenshots directory exists +if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); +} + +test.describe('VegaDeclarativeChart - 1000+ Schema Screenshots', () => { + // Disable retries for batch tests (each batch handles errors internally) + test.describe.configure({ retries: 0 }); + + for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { + const batch = batches[batchIndex]; + const batchStart = batchIndex * BATCH_SIZE + 1; + const batchEnd = batchStart + batch.length - 1; + + test(`batch ${batchIndex + 1}: schemas ${batchStart}-${batchEnd}`, async ({ page }) => { + // Navigate to the story once per batch + await page.goto(storyUrl); + await page.waitForLoadState('networkidle'); + + // Wait for the textarea to be available + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible({ timeout: 15000 }); + + for (const schemaFile of batch) { + const schemaPath = path.join(SCHEMAS_DIR, schemaFile); + const schemaName = schemaFile.replace('.json', ''); + const screenshotPath = path.join(screenshotsDir, `${schemaName}.png`); + + try { + // Read the schema file + const schemaJson = fs.readFileSync(schemaPath, 'utf-8'); + + // Fill the textarea with the new schema + await textarea.fill(schemaJson); + + // Wait for chart to re-render (5s needed for complex schemas) + await page.waitForTimeout(5000); + + // Take screenshot + await page.screenshot({ + path: screenshotPath, + fullPage: true, + }); + } catch (err) { + // Log error but continue with next schema + console.error(`Failed to screenshot ${schemaFile}: ${err}`); + } + } + }); + } +}); diff --git a/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.spec.ts b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.spec.ts new file mode 100644 index 00000000000000..a901a6b0cd3c3a --- /dev/null +++ b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; + +const storyUrl = '?id=charts-vegadeclarativechart--default&viewMode=story'; + +// All 25 chart keys from VegaDeclarativeChartDefault.stories.tsx ALL_SCHEMAS +// Map from key to display text (matching the story's text generation logic) +const ALL_CHARTS: Array<{ key: string; text: string }> = [ + 'adCtrScatter', + 'ageDistributionBar', + 'airQualityHeatmap', + 'apiResponseLine', + 'areaMultiSeriesNoStack', + 'areaSingleTozeroy', + 'areaStackedTonexty', + 'areachart', + 'attendanceBar', + 'attendanceHeatmap', + 'bandwidthStackedArea', + 'barchart', + 'biodiversityGrouped', + 'bmiScatter', + 'budgetActualGrouped', + 'bugPriorityDonut', + 'campaignPerformanceCombo', + 'cashflowCombo', + 'categorySalesStacked', + 'channelDistributionDonut', + 'climateZonesScatter', + 'co2EmissionsArea', + 'codeCommitsCombo', + 'conversionFunnel', + 'courseEnrollmentDonut', +].map(key => ({ + key, + text: key + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '), +})); + +const screenshotsDir = path.resolve(__dirname, '../../screenshots'); + +test.describe('VegaDeclarativeChart - Screenshot Tests', () => { + for (const { key: chartKey, text: chartText } of ALL_CHARTS) { + test(`should render ${chartKey} correctly`, async ({ page }) => { + // Navigate to the story + await page.goto(storyUrl); + + // Wait for the page to be fully loaded + await page.waitForLoadState('networkidle'); + + // Locate the "Chart Type" dropdown using its Field label association + const chartDropdown = page.getByRole('combobox', { name: 'Chart Type' }); + await expect(chartDropdown).toBeVisible({ timeout: 15000 }); + + // Click the Chart Type dropdown to open it + await chartDropdown.click(); + + // Wait for the listbox popup to appear and select the option by text + const option = page.getByRole('option', { name: chartText, exact: true }); + await expect(option).toBeVisible({ timeout: 5000 }); + await option.click(); + + // Wait for the chart to render + await page.waitForTimeout(2000); + + // Take a screenshot of the full page (includes chart preview) + await page.screenshot({ + path: path.join(screenshotsDir, `${chartKey}.png`), + fullPage: true, + }); + }); + } +}); diff --git a/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.timeout-test.spec.ts b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.timeout-test.spec.ts new file mode 100644 index 00000000000000..ab8b43bb6ab1e8 --- /dev/null +++ b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChart.timeout-test.spec.ts @@ -0,0 +1,137 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const storyUrl = '?id=charts-vegadeclarativechart--default&viewMode=story'; +const SCHEMAS_DIR = 'C:\\Users\\atisjai\\dev\\fluentui-charting-contrib\\vega_data'; +const screenshotsDir = path.resolve(__dirname, '../../screenshots/timeout_test'); + +// Sample of BLANK schemas from the evaluation +const BLANK_SAMPLES = [ + 'data_002_vega.json', + 'data_046_vega.json', + 'data_087_vega.json', + 'data_149_vega.json', + 'data_200_vega.json', + 'data_250_vega.json', + 'data_277_vega.json', + 'data_336_vega.json', + 'data_439_vega.json', + 'data_541_vega.json', + 'data_597_vega.json', + 'data_886_vega.json', + 'data_937_vega.json', + 'data_1013_vega.json', + 'data_1066_vega.json', +]; + +// Sample of ERROR schemas to capture exact error messages +const ERROR_SAMPLES = [ + 'data_008_vega.json', + 'data_344_vega.json', + 'data_500_vega.json', + 'data_550_vega.json', + 'data_800_vega.json', + 'data_900_vega.json', + 'data_953_vega.json', + 'data_1107_vega.json', +]; + +if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); +} + +test.describe('Timeout & Root Cause Investigation', () => { + test.describe.configure({ retries: 0 }); + + test('BLANK schemas with 5s timeout', async ({ page }) => { + await page.goto(storyUrl); + await page.waitForLoadState('networkidle'); + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible({ timeout: 15000 }); + + for (const schemaFile of BLANK_SAMPLES) { + const schemaPath = path.join(SCHEMAS_DIR, schemaFile); + if (!fs.existsSync(schemaPath)) continue; + const schemaName = schemaFile.replace('.json', ''); + const schemaJson = fs.readFileSync(schemaPath, 'utf-8'); + + // Fill and wait with LONGER timeout (5s instead of 1.5s) + await textarea.fill(schemaJson); + await page.waitForTimeout(5000); + + await page.screenshot({ + path: path.join(screenshotsDir, `blank_5s_${schemaName}.png`), + fullPage: true, + }); + } + }); + + test('BLANK schemas with 10s timeout', async ({ page }) => { + await page.goto(storyUrl); + await page.waitForLoadState('networkidle'); + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible({ timeout: 15000 }); + + // Test just 5 blanks with even longer timeout + for (const schemaFile of BLANK_SAMPLES.slice(0, 5)) { + const schemaPath = path.join(SCHEMAS_DIR, schemaFile); + if (!fs.existsSync(schemaPath)) continue; + const schemaName = schemaFile.replace('.json', ''); + const schemaJson = fs.readFileSync(schemaPath, 'utf-8'); + + await textarea.fill(schemaJson); + await page.waitForTimeout(10000); + + await page.screenshot({ + path: path.join(screenshotsDir, `blank_10s_${schemaName}.png`), + fullPage: true, + }); + } + }); + + test('ERROR schemas - capture error text', async ({ page }) => { + await page.goto(storyUrl); + await page.waitForLoadState('networkidle'); + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible({ timeout: 15000 }); + + const results: string[] = []; + + for (const schemaFile of ERROR_SAMPLES) { + const schemaPath = path.join(SCHEMAS_DIR, schemaFile); + if (!fs.existsSync(schemaPath)) continue; + const schemaName = schemaFile.replace('.json', ''); + const schemaJson = fs.readFileSync(schemaPath, 'utf-8'); + + await textarea.fill(schemaJson); + await page.waitForTimeout(2000); + + // Try to extract error text from the error boundary + const errorEl = page.locator('h3:has-text("Error rendering chart")'); + const hasError = await errorEl.count() > 0; + + if (hasError) { + // Get the error container text + const errorContainer = page.locator('div').filter({ has: errorEl }).first(); + const errorText = await errorContainer.innerText(); + // Extract just the first line of the error + const firstLine = errorText.split('\n').slice(1, 3).join(' | '); + results.push(`ERROR: ${schemaName} → ${firstLine}`); + } else { + results.push(`NO_ERROR: ${schemaName}`); + } + + await page.screenshot({ + path: path.join(screenshotsDir, `error_${schemaName}.png`), + fullPage: true, + }); + } + + // Log all results + console.log('\n=== ERROR MESSAGE ANALYSIS ==='); + for (const r of results) { + console.log(r); + } + }); +}); diff --git a/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChartDefault.stories.tsx b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChartDefault.stories.tsx new file mode 100644 index 00000000000000..07ca4ad544de7b --- /dev/null +++ b/packages/charts/react-charts/stories/src/VegaDeclarativeChart/VegaDeclarativeChartDefault.stories.tsx @@ -0,0 +1,1304 @@ +import * as React from 'react'; +import { VegaDeclarativeChart } from '@fluentui/react-charts'; +import { + Button, + Dropdown, + Field, + Input, + InputOnChangeData, + Option, + OptionOnSelectData, + SelectionEvents, + Spinner, + Switch, +} from '@fluentui/react-components'; + +// Inline schemas (25 total covering various chart types) +// These are the default schemas shown in "show few" mode +const ALL_SCHEMAS: Record = { + adCtrScatter: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Ad click-through rate analysis', + data: { + values: [ + { impressions: 50000, clicks: 1250, campaign: 'Summer Sale', ctr: 2.5 }, + { impressions: 75000, clicks: 2625, campaign: 'Back to School', ctr: 3.5 }, + { impressions: 120000, clicks: 3600, campaign: 'Holiday Special', ctr: 3.0 }, + { impressions: 45000, clicks: 1800, campaign: 'Flash Deal', ctr: 4.0 }, + { impressions: 90000, clicks: 3150, campaign: 'Spring Collection', ctr: 3.5 }, + { impressions: 60000, clicks: 1440, campaign: 'Clearance', ctr: 2.4 }, + { impressions: 100000, clicks: 4500, campaign: 'Black Friday', ctr: 4.5 }, + ], + }, + mark: 'point', + encoding: { + x: { field: 'impressions', type: 'quantitative', axis: { title: 'Impressions', format: ',.0f' } }, + y: { field: 'ctr', type: 'quantitative', axis: { title: 'Click-Through Rate (%)' } }, + size: { + field: 'clicks', + type: 'quantitative', + legend: { title: 'Total Clicks' }, + scale: { range: [100, 1000] }, + }, + color: { field: 'ctr', type: 'quantitative', scale: { scheme: 'blues' }, legend: null }, + tooltip: [ + { field: 'campaign', type: 'nominal' }, + { field: 'impressions', type: 'quantitative', format: ',.0f' }, + { field: 'clicks', type: 'quantitative', format: ',.0f' }, + { field: 'ctr', type: 'quantitative', format: '.1f', title: 'CTR %' }, + ], + }, + title: 'Ad Performance - CTR Analysis', + }, + ageDistributionBar: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Patient age distribution', + data: { + values: [ + { ageGroup: '0-10', patients: 145 }, + { ageGroup: '11-20', patients: 98 }, + { ageGroup: '21-30', patients: 234 }, + { ageGroup: '31-40', patients: 312 }, + { ageGroup: '41-50', patients: 287 }, + { ageGroup: '51-60', patients: 342 }, + { ageGroup: '61-70', patients: 398 }, + { ageGroup: '71-80', patients: 276 }, + { ageGroup: '81+', patients: 189 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'ageGroup', type: 'ordinal', axis: { title: 'Age Group', labelAngle: 0 } }, + y: { field: 'patients', type: 'quantitative', axis: { title: 'Number of Patients' } }, + color: { field: 'patients', type: 'quantitative', scale: { scheme: 'teal' }, legend: null }, + }, + title: 'Patient Age Distribution', + }, + airQualityHeatmap: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Air quality index by location', + data: { + values: [ + { city: 'New York', time: 'Morning', aqi: 45 }, + { city: 'New York', time: 'Afternoon', aqi: 62 }, + { city: 'New York', time: 'Evening', aqi: 58 }, + { city: 'Los Angeles', time: 'Morning', aqi: 85 }, + { city: 'Los Angeles', time: 'Afternoon', aqi: 95 }, + { city: 'Los Angeles', time: 'Evening', aqi: 78 }, + { city: 'Chicago', time: 'Morning', aqi: 52 }, + { city: 'Chicago', time: 'Afternoon', aqi: 68 }, + { city: 'Chicago', time: 'Evening', aqi: 61 }, + { city: 'Houston', time: 'Morning', aqi: 72 }, + { city: 'Houston', time: 'Afternoon', aqi: 88 }, + { city: 'Houston', time: 'Evening', aqi: 75 }, + ], + }, + mark: 'rect', + encoding: { + x: { field: 'time', type: 'ordinal', axis: { title: 'Time of Day' } }, + y: { field: 'city', type: 'ordinal', axis: { title: 'City' } }, + color: { + field: 'aqi', + type: 'quantitative', + scale: { scheme: 'redyellowgreen', domain: [0, 150], reverse: true }, + legend: { title: 'AQI' }, + }, + tooltip: [ + { field: 'city', type: 'ordinal' }, + { field: 'time', type: 'ordinal' }, + { field: 'aqi', type: 'quantitative', title: 'Air Quality Index' }, + ], + }, + title: 'Air Quality Index Heatmap', + }, + apiResponseLine: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'API response time monitoring', + data: { + values: [ + { timestamp: '2024-11-27T08:00:00', responseTime: 145 }, + { timestamp: '2024-11-27T09:00:00', responseTime: 132 }, + { timestamp: '2024-11-27T10:00:00', responseTime: 158 }, + { timestamp: '2024-11-27T11:00:00', responseTime: 142 }, + { timestamp: '2024-11-27T12:00:00', responseTime: 178 }, + { timestamp: '2024-11-27T13:00:00', responseTime: 165 }, + { timestamp: '2024-11-27T14:00:00', responseTime: 152 }, + { timestamp: '2024-11-27T15:00:00', responseTime: 138 }, + { timestamp: '2024-11-27T16:00:00', responseTime: 148 }, + { timestamp: '2024-11-27T17:00:00', responseTime: 156 }, + ], + }, + mark: { type: 'line', point: true, strokeWidth: 2 }, + encoding: { + x: { field: 'timestamp', type: 'temporal', axis: { title: 'Time', format: '%H:%M' } }, + y: { field: 'responseTime', type: 'quantitative', axis: { title: 'Response Time (ms)' } }, + tooltip: [ + { field: 'timestamp', type: 'temporal', format: '%H:%M' }, + { field: 'responseTime', type: 'quantitative', title: 'Response (ms)' }, + ], + }, + title: 'API Response Time Monitoring', + }, + areaMultiSeriesNoStack: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Multiple area series without stacking - each fills to zero independently', + title: 'Department Performance - Overlapping Areas (No Stack)', + width: 600, + height: 300, + data: { + values: [ + { quarter: 'Q1 2024', department: 'Sales', performance: 85 }, + { quarter: 'Q1 2024', department: 'Marketing', performance: 70 }, + { quarter: 'Q1 2024', department: 'Support', performance: 60 }, + { quarter: 'Q2 2024', department: 'Sales', performance: 88 }, + { quarter: 'Q2 2024', department: 'Marketing', performance: 75 }, + { quarter: 'Q2 2024', department: 'Support', performance: 65 }, + { quarter: 'Q3 2024', department: 'Sales', performance: 92 }, + { quarter: 'Q3 2024', department: 'Marketing', performance: 82 }, + { quarter: 'Q3 2024', department: 'Support', performance: 70 }, + { quarter: 'Q4 2024', department: 'Sales', performance: 95 }, + { quarter: 'Q4 2024', department: 'Marketing', performance: 88 }, + { quarter: 'Q4 2024', department: 'Support', performance: 75 }, + ], + }, + mark: { + type: 'area', + fillOpacity: 0.5, + }, + encoding: { + x: { + field: 'quarter', + type: 'ordinal', + axis: { + title: 'Quarter', + }, + }, + y: { + field: 'performance', + type: 'quantitative', + stack: null, + axis: { + title: 'Performance Score', + }, + }, + color: { + field: 'department', + type: 'nominal', + legend: { + title: 'Department', + }, + }, + }, + }, + areaSingleTozeroy: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Single series area chart with fill to zero baseline (tozeroy mode)', + title: 'Monthly Revenue - Single Series (Fill to Zero)', + width: 600, + height: 300, + data: { + values: [ + { month: '2024-01', revenue: 12000 }, + { month: '2024-02', revenue: 15000 }, + { month: '2024-03', revenue: 18000 }, + { month: '2024-04', revenue: 16000 }, + { month: '2024-05', revenue: 22000 }, + { month: '2024-06', revenue: 25000 }, + { month: '2024-07', revenue: 28000 }, + { month: '2024-08', revenue: 26000 }, + { month: '2024-09', revenue: 30000 }, + { month: '2024-10', revenue: 32000 }, + { month: '2024-11', revenue: 35000 }, + { month: '2024-12', revenue: 38000 }, + ], + }, + mark: 'area', + encoding: { + x: { + field: 'month', + type: 'temporal', + axis: { + title: 'Month', + format: '%b %Y', + }, + }, + y: { + field: 'revenue', + type: 'quantitative', + stack: null, + axis: { + title: 'Revenue ($)', + format: '$,.0f', + }, + }, + }, + }, + areaStackedTonexty: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Stacked area chart with multiple series (tonexty mode)', + title: 'Product Sales by Category - Stacked Areas', + width: 600, + height: 300, + data: { + values: [ + { month: '2024-01', category: 'Electronics', sales: 5000 }, + { month: '2024-01', category: 'Clothing', sales: 3000 }, + { month: '2024-01', category: 'Home', sales: 2000 }, + { month: '2024-02', category: 'Electronics', sales: 5500 }, + { month: '2024-02', category: 'Clothing', sales: 3500 }, + { month: '2024-02', category: 'Home', sales: 2200 }, + { month: '2024-03', category: 'Electronics', sales: 6000 }, + { month: '2024-03', category: 'Clothing', sales: 4000 }, + { month: '2024-03', category: 'Home', sales: 2500 }, + { month: '2024-04', category: 'Electronics', sales: 5800 }, + { month: '2024-04', category: 'Clothing', sales: 3800 }, + { month: '2024-04', category: 'Home', sales: 2300 }, + { month: '2024-05', category: 'Electronics', sales: 6500 }, + { month: '2024-05', category: 'Clothing', sales: 4200 }, + { month: '2024-05', category: 'Home', sales: 2700 }, + { month: '2024-06', category: 'Electronics', sales: 7000 }, + { month: '2024-06', category: 'Clothing', sales: 4500 }, + { month: '2024-06', category: 'Home', sales: 3000 }, + { month: '2024-07', category: 'Electronics', sales: 7500 }, + { month: '2024-07', category: 'Clothing', sales: 5000 }, + { month: '2024-07', category: 'Home', sales: 3200 }, + { month: '2024-08', category: 'Electronics', sales: 7200 }, + { month: '2024-08', category: 'Clothing', sales: 4800 }, + { month: '2024-08', category: 'Home', sales: 3000 }, + { month: '2024-09', category: 'Electronics', sales: 8000 }, + { month: '2024-09', category: 'Clothing', sales: 5500 }, + { month: '2024-09', category: 'Home', sales: 3500 }, + { month: '2024-10', category: 'Electronics', sales: 8500 }, + { month: '2024-10', category: 'Clothing', sales: 6000 }, + { month: '2024-10', category: 'Home', sales: 3800 }, + { month: '2024-11', category: 'Electronics', sales: 9000 }, + { month: '2024-11', category: 'Clothing', sales: 6500 }, + { month: '2024-11', category: 'Home', sales: 4000 }, + { month: '2024-12', category: 'Electronics', sales: 9500 }, + { month: '2024-12', category: 'Clothing', sales: 7000 }, + { month: '2024-12', category: 'Home', sales: 4500 }, + ], + }, + mark: 'area', + encoding: { + x: { + field: 'month', + type: 'temporal', + axis: { + title: 'Month', + format: '%b', + }, + }, + y: { + field: 'sales', + type: 'quantitative', + stack: 'zero', + axis: { + title: 'Sales ($)', + format: '$,.0f', + }, + }, + color: { + field: 'category', + type: 'nominal', + legend: { + title: 'Product Category', + }, + }, + }, + }, + areachart: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Area chart with temporal data.', + data: { + values: [ + { date: '2023-01-01', value: 28, category: 'A' }, + { date: '2023-01-02', value: 55, category: 'A' }, + { date: '2023-01-03', value: 43, category: 'A' }, + { date: '2023-01-04', value: 91, category: 'A' }, + { date: '2023-01-05', value: 81, category: 'A' }, + { date: '2023-01-01', value: 20, category: 'B' }, + { date: '2023-01-02', value: 40, category: 'B' }, + { date: '2023-01-03', value: 30, category: 'B' }, + { date: '2023-01-04', value: 70, category: 'B' }, + { date: '2023-01-05', value: 60, category: 'B' }, + ], + }, + mark: 'area', + encoding: { + x: { field: 'date', type: 'temporal', axis: { title: 'Date' } }, + y: { field: 'value', type: 'quantitative', axis: { title: 'Value' } }, + color: { field: 'category', type: 'nominal' }, + }, + title: 'Simple Area Chart', + }, + attendanceBar: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Stadium attendance figures', + data: { + values: [ + { game: 'Game 1', attendance: 42500 }, + { game: 'Game 2', attendance: 45200 }, + { game: 'Game 3', attendance: 38900 }, + { game: 'Game 4', attendance: 51000 }, + { game: 'Game 5', attendance: 48700 }, + { game: 'Game 6', attendance: 52500 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'game', type: 'ordinal', axis: { title: 'Game' } }, + y: { field: 'attendance', type: 'quantitative', axis: { title: 'Attendance', format: ',.0f' } }, + color: { field: 'attendance', type: 'quantitative', scale: { scheme: 'oranges' }, legend: null }, + }, + title: 'Home Game Attendance', + }, + attendanceHeatmap: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Class attendance patterns', + data: { + values: [ + { day: 'Monday', period: 'Period 1', attendance: 92 }, + { day: 'Monday', period: 'Period 2', attendance: 89 }, + { day: 'Monday', period: 'Period 3', attendance: 87 }, + { day: 'Monday', period: 'Period 4', attendance: 85 }, + { day: 'Tuesday', period: 'Period 1', attendance: 90 }, + { day: 'Tuesday', period: 'Period 2', attendance: 88 }, + { day: 'Tuesday', period: 'Period 3', attendance: 91 }, + { day: 'Tuesday', period: 'Period 4', attendance: 86 }, + { day: 'Wednesday', period: 'Period 1', attendance: 94 }, + { day: 'Wednesday', period: 'Period 2', attendance: 92 }, + { day: 'Wednesday', period: 'Period 3', attendance: 90 }, + { day: 'Wednesday', period: 'Period 4', attendance: 88 }, + { day: 'Thursday', period: 'Period 1', attendance: 91 }, + { day: 'Thursday', period: 'Period 2', attendance: 89 }, + { day: 'Thursday', period: 'Period 3', attendance: 87 }, + { day: 'Thursday', period: 'Period 4', attendance: 84 }, + { day: 'Friday', period: 'Period 1', attendance: 88 }, + { day: 'Friday', period: 'Period 2', attendance: 85 }, + { day: 'Friday', period: 'Period 3', attendance: 83 }, + { day: 'Friday', period: 'Period 4', attendance: 79 }, + ], + }, + mark: 'rect', + encoding: { + x: { field: 'day', type: 'ordinal', axis: { title: 'Day of Week' } }, + y: { field: 'period', type: 'ordinal', axis: { title: 'Class Period' } }, + color: { + field: 'attendance', + type: 'quantitative', + scale: { scheme: 'blues' }, + legend: { title: 'Attendance %' }, + }, + tooltip: [ + { field: 'day', type: 'ordinal' }, + { field: 'period', type: 'ordinal' }, + { field: 'attendance', type: 'quantitative', title: 'Attendance %', format: '.0f' }, + ], + }, + title: 'Weekly Attendance Patterns', + }, + bandwidthStackedArea: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Network bandwidth usage', + data: { + values: [ + { hour: '2024-11-27T00:00:00', inbound: 125, outbound: 95 }, + { hour: '2024-11-27T04:00:00', inbound: 85, outbound: 65 }, + { hour: '2024-11-27T08:00:00', inbound: 245, outbound: 185 }, + { hour: '2024-11-27T12:00:00', inbound: 385, outbound: 295 }, + { hour: '2024-11-27T16:00:00', inbound: 425, outbound: 325 }, + { hour: '2024-11-27T20:00:00', inbound: 285, outbound: 215 }, + ], + }, + transform: [{ fold: ['inbound', 'outbound'], as: ['direction', 'bandwidth'] }], + mark: 'area', + encoding: { + x: { field: 'hour', type: 'temporal', axis: { title: 'Hour', format: '%H:%M' } }, + y: { field: 'bandwidth', type: 'quantitative', axis: { title: 'Bandwidth (Mbps)' } }, + color: { field: 'direction', type: 'nominal', legend: { title: 'Direction' } }, + opacity: { value: 0.7 }, + }, + title: 'Network Bandwidth Usage', + }, + barchart: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'A simple bar chart.', + data: { + values: [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + { category: 'C', value: 43 }, + { category: 'D', value: 91 }, + { category: 'E', value: 81 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'value', type: 'quantitative', axis: { title: 'Value' } }, + y: { field: 'category', type: 'nominal', axis: { title: 'Category' } }, + }, + title: 'Horizontal Bar Chart', + }, + biodiversityGrouped: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Biodiversity counts by region', + data: { + values: [ + { region: 'Amazon', category: 'Mammals', count: 427 }, + { region: 'Amazon', category: 'Birds', count: 1300 }, + { region: 'Amazon', category: 'Reptiles', count: 378 }, + { region: 'Congo', category: 'Mammals', count: 268 }, + { region: 'Congo', category: 'Birds', count: 700 }, + { region: 'Congo', category: 'Reptiles', count: 280 }, + { region: 'Borneo', category: 'Mammals', count: 222 }, + { region: 'Borneo', category: 'Birds', count: 420 }, + { region: 'Borneo', category: 'Reptiles', count: 254 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'region', type: 'nominal', axis: { title: 'Region' } }, + y: { field: 'count', type: 'quantitative', axis: { title: 'Species Count' } }, + color: { field: 'category', type: 'nominal', legend: { title: 'Category' } }, + xOffset: { field: 'category' }, + }, + title: 'Biodiversity by Rainforest Region', + }, + bmiScatter: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'BMI distribution analysis', + data: { + values: [ + { height: 160, weight: 52, bmi: 20.3, category: 'Normal' }, + { height: 165, weight: 68, bmi: 25.0, category: 'Overweight' }, + { height: 170, weight: 75, bmi: 25.9, category: 'Overweight' }, + { height: 175, weight: 70, bmi: 22.9, category: 'Normal' }, + { height: 180, weight: 95, bmi: 29.3, category: 'Overweight' }, + { height: 158, weight: 45, bmi: 18.0, category: 'Underweight' }, + { height: 172, weight: 82, bmi: 27.7, category: 'Overweight' }, + { height: 168, weight: 58, bmi: 20.5, category: 'Normal' }, + { height: 177, weight: 88, bmi: 28.1, category: 'Overweight' }, + { height: 162, weight: 48, bmi: 18.3, category: 'Underweight' }, + ], + }, + mark: 'point', + encoding: { + x: { field: 'height', type: 'quantitative', axis: { title: 'Height (cm)' } }, + y: { field: 'weight', type: 'quantitative', axis: { title: 'Weight (kg)' } }, + color: { + field: 'category', + type: 'nominal', + scale: { domain: ['Underweight', 'Normal', 'Overweight'], range: ['#ff7f0e', '#2ca02c', '#d62728'] }, + legend: { title: 'BMI Category' }, + }, + size: { value: 100 }, + tooltip: [ + { field: 'height', type: 'quantitative', title: 'Height (cm)' }, + { field: 'weight', type: 'quantitative', title: 'Weight (kg)' }, + { field: 'bmi', type: 'quantitative', title: 'BMI', format: '.1f' }, + { field: 'category', type: 'nominal', title: 'Category' }, + ], + }, + title: 'BMI Distribution Scatter', + }, + budgetActualGrouped: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Budget vs Actual spending by department', + data: { + values: [ + { department: 'Marketing', category: 'Budget', amount: 250000 }, + { department: 'Marketing', category: 'Actual', amount: 235000 }, + { department: 'Engineering', category: 'Budget', amount: 450000 }, + { department: 'Engineering', category: 'Actual', amount: 478000 }, + { department: 'Sales', category: 'Budget', amount: 320000 }, + { department: 'Sales', category: 'Actual', amount: 310000 }, + { department: 'Operations', category: 'Budget', amount: 180000 }, + { department: 'Operations', category: 'Actual', amount: 192000 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'department', type: 'nominal', axis: { title: 'Department' } }, + y: { field: 'amount', type: 'quantitative', axis: { title: 'Amount ($)', format: '$,.0f' } }, + color: { + field: 'category', + type: 'nominal', + scale: { domain: ['Budget', 'Actual'], range: ['#1f77b4', '#ff7f0e'] }, + }, + xOffset: { field: 'category' }, + }, + title: 'Budget vs Actual - Department Comparison', + }, + bugPriorityDonut: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Bug priority distribution', + data: { + values: [ + { priority: 'Critical', count: 12 }, + { priority: 'High', count: 28 }, + { priority: 'Medium', count: 45 }, + { priority: 'Low', count: 67 }, + ], + }, + mark: { type: 'arc', innerRadius: 55 }, + encoding: { + theta: { field: 'count', type: 'quantitative' }, + color: { + field: 'priority', + type: 'nominal', + scale: { + domain: ['Critical', 'High', 'Medium', 'Low'], + range: ['#d62728', '#ff7f0e', '#ffcc00', '#2ca02c'], + }, + legend: { title: 'Priority' }, + }, + tooltip: [ + { field: 'priority', type: 'nominal' }, + { field: 'count', type: 'quantitative', title: 'Open Bugs' }, + ], + }, + title: 'Open Bugs by Priority', + }, + campaignPerformanceCombo: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Marketing campaign performance', + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'week', type: 'temporal', axis: { title: 'Week', format: '%b %d' } }, + y: { field: 'spend', type: 'quantitative', axis: { title: 'Spend ($)', format: '$,.0f' } }, + color: { value: 'lightblue' }, + }, + }, + { + mark: { type: 'line', point: true, color: 'darkgreen' }, + encoding: { + x: { field: 'week', type: 'temporal' }, + y: { field: 'conversions', type: 'quantitative', axis: { title: 'Conversions' } }, + }, + }, + ], + data: { + values: [ + { week: '2024-10-01', spend: 12000, conversions: 240 }, + { week: '2024-10-08', spend: 15000, conversions: 315 }, + { week: '2024-10-15', spend: 18000, conversions: 378 }, + { week: '2024-10-22', spend: 14000, conversions: 294 }, + { week: '2024-10-29', spend: 16000, conversions: 336 }, + { week: '2024-11-05', spend: 20000, conversions: 440 }, + ], + }, + resolve: { scale: { y: 'independent' } }, + title: 'Campaign Spend vs Conversions', + }, + cashflowCombo: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Monthly cash flow analysis', + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'month', type: 'temporal', axis: { title: 'Month', format: '%b' } }, + y: { field: 'cashflow', type: 'quantitative', axis: { title: 'Cash Flow ($)', format: '$,.0f' } }, + color: { + condition: { + test: 'datum.cashflow > 0', + value: '#2ca02c', + }, + value: '#d62728', + }, + }, + }, + { + mark: { type: 'line', point: true, color: 'orange' }, + encoding: { + x: { field: 'month', type: 'temporal' }, + y: { field: 'balance', type: 'quantitative', axis: { title: 'Running Balance' } }, + }, + }, + ], + data: { + values: [ + { month: '2024-01', cashflow: 85000, balance: 285000 }, + { month: '2024-02', cashflow: -42000, balance: 243000 }, + { month: '2024-03', cashflow: 95000, balance: 338000 }, + { month: '2024-04', cashflow: 112000, balance: 450000 }, + { month: '2024-05', cashflow: -28000, balance: 422000 }, + { month: '2024-06', cashflow: 138000, balance: 560000 }, + ], + }, + resolve: { scale: { y: 'independent' } }, + title: 'Cash Flow Analysis', + }, + categorySalesStacked: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Category sales breakdown', + data: { + values: [ + { quarter: 'Q1', category: 'Electronics', sales: 125000 }, + { quarter: 'Q1', category: 'Clothing', sales: 78000 }, + { quarter: 'Q1', category: 'Home & Garden', sales: 52000 }, + { quarter: 'Q1', category: 'Sports', sales: 38000 }, + { quarter: 'Q2', category: 'Electronics', sales: 142000 }, + { quarter: 'Q2', category: 'Clothing', sales: 95000 }, + { quarter: 'Q2', category: 'Home & Garden', sales: 68000 }, + { quarter: 'Q2', category: 'Sports', sales: 45000 }, + { quarter: 'Q3', category: 'Electronics', sales: 138000 }, + { quarter: 'Q3', category: 'Clothing', sales: 88000 }, + { quarter: 'Q3', category: 'Home & Garden', sales: 61000 }, + { quarter: 'Q3', category: 'Sports', sales: 52000 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'quarter', type: 'nominal', axis: { title: 'Quarter' } }, + y: { field: 'sales', type: 'quantitative', axis: { title: 'Sales ($)', format: '$,.0f' } }, + color: { field: 'category', type: 'nominal', legend: { title: 'Category' } }, + }, + title: 'Category Sales - Stacked View', + }, + channelDistributionDonut: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Marketing channel distribution', + data: { + values: [ + { channel: 'Email', budget: 45000 }, + { channel: 'Social Media', budget: 85000 }, + { channel: 'Search Ads', budget: 120000 }, + { channel: 'Display Ads', budget: 65000 }, + { channel: 'Content Marketing', budget: 55000 }, + { channel: 'Influencer', budget: 75000 }, + ], + }, + mark: { type: 'arc', innerRadius: 60, padAngle: 0.015 }, + encoding: { + theta: { field: 'budget', type: 'quantitative' }, + color: { + field: 'channel', + type: 'nominal', + scale: { scheme: 'set2' }, + legend: { title: 'Marketing Channel' }, + }, + tooltip: [ + { field: 'channel', type: 'nominal' }, + { field: 'budget', type: 'quantitative', format: '$,.0f', title: 'Budget' }, + ], + }, + title: 'Marketing Budget by Channel', + }, + climateZonesScatter: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Climate zone temperature distribution', + data: { + values: [ + { avgTemp: -15, precipitation: 250, zone: 'Arctic' }, + { avgTemp: 5, precipitation: 800, zone: 'Temperate' }, + { avgTemp: 15, precipitation: 650, zone: 'Subtropical' }, + { avgTemp: 25, precipitation: 2000, zone: 'Tropical' }, + { avgTemp: -8, precipitation: 300, zone: 'Subarctic' }, + { avgTemp: 22, precipitation: 450, zone: 'Arid' }, + { avgTemp: 18, precipitation: 900, zone: 'Mediterranean' }, + ], + }, + mark: { type: 'point', size: 200 }, + encoding: { + x: { field: 'avgTemp', type: 'quantitative', axis: { title: 'Average Temperature (°C)' } }, + y: { field: 'precipitation', type: 'quantitative', axis: { title: 'Annual Precipitation (mm)' } }, + color: { field: 'zone', type: 'nominal', legend: { title: 'Climate Zone' } }, + tooltip: [ + { field: 'zone', type: 'nominal', title: 'Zone' }, + { field: 'avgTemp', type: 'quantitative', title: 'Avg Temp (°C)' }, + { field: 'precipitation', type: 'quantitative', title: 'Precipitation (mm)' }, + ], + }, + title: 'Climate Zones Classification', + }, + co2EmissionsArea: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'CO2 emissions over time', + data: { + values: [ + { year: '1990', emissions: 22.7 }, + { year: '1995', emissions: 23.5 }, + { year: '2000', emissions: 25.1 }, + { year: '2005', emissions: 28.8 }, + { year: '2010', emissions: 32.5 }, + { year: '2015', emissions: 35.2 }, + { year: '2020', emissions: 33.8 }, + { year: '2023', emissions: 36.1 }, + ], + }, + mark: { type: 'area', line: true, point: true, color: 'gray', opacity: 0.7 }, + encoding: { + x: { field: 'year', type: 'ordinal', axis: { title: 'Year' } }, + y: { field: 'emissions', type: 'quantitative', axis: { title: 'CO₂ Emissions (Gt)', format: '.1f' } }, + tooltip: [ + { field: 'year', type: 'ordinal' }, + { field: 'emissions', type: 'quantitative', title: 'Emissions (Gt)', format: '.1f' }, + ], + }, + title: 'Global CO₂ Emissions Trend', + }, + codeCommitsCombo: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Code commit activity', + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'week', type: 'ordinal', axis: { title: 'Week' } }, + y: { field: 'commits', type: 'quantitative', axis: { title: 'Commits' } }, + color: { value: 'lightcoral' }, + }, + }, + { + mark: { type: 'line', point: true, color: 'navy' }, + encoding: { + x: { field: 'week', type: 'ordinal' }, + y: { field: 'contributors', type: 'quantitative', axis: { title: 'Active Contributors' } }, + }, + }, + ], + data: { + values: [ + { week: 'Week 1', commits: 142, contributors: 12 }, + { week: 'Week 2', commits: 158, contributors: 14 }, + { week: 'Week 3', commits: 135, contributors: 11 }, + { week: 'Week 4', commits: 178, contributors: 15 }, + { week: 'Week 5', commits: 165, contributors: 13 }, + { week: 'Week 6', commits: 192, contributors: 16 }, + ], + }, + resolve: { scale: { y: 'independent' } }, + title: 'Code Commits & Contributors', + }, + conversionFunnel: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'E-commerce conversion funnel', + data: { + values: [ + { stage: 'Page Views', count: 50000 }, + { stage: 'Product Views', count: 12500 }, + { stage: 'Add to Cart', count: 3750 }, + { stage: 'Checkout Started', count: 2250 }, + { stage: 'Payment Info', count: 1800 }, + { stage: 'Order Completed', count: 1575 }, + ], + }, + mark: 'bar', + encoding: { + y: { field: 'stage', type: 'nominal', axis: { title: 'Funnel Stage' }, sort: null }, + x: { field: 'count', type: 'quantitative', axis: { title: 'Users', format: ',.0f' } }, + color: { field: 'count', type: 'quantitative', scale: { scheme: 'blues' }, legend: null }, + }, + title: 'Conversion Funnel Analysis', + }, + courseEnrollmentDonut: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'Course enrollment breakdown', + data: { + values: [ + { course: 'Computer Science', enrolled: 450 }, + { course: 'Engineering', enrolled: 380 }, + { course: 'Business', enrolled: 520 }, + { course: 'Arts & Humanities', enrolled: 290 }, + { course: 'Natural Sciences', enrolled: 360 }, + { course: 'Social Sciences', enrolled: 310 }, + ], + }, + mark: { type: 'arc', innerRadius: 65, padAngle: 0.01 }, + encoding: { + theta: { field: 'enrolled', type: 'quantitative' }, + color: { + field: 'course', + type: 'nominal', + scale: { scheme: 'category20' }, + legend: { title: 'Course' }, + }, + tooltip: [ + { field: 'course', type: 'nominal' }, + { field: 'enrolled', type: 'quantitative', title: 'Students', format: ',.0f' }, + ], + }, + title: 'Course Enrollment Distribution', + }, +}; + +const SCHEMA_NAMES: string[] = Object.keys(ALL_SCHEMAS); + +// Convert ALL_SCHEMAS to array format for easier handling +const DEFAULT_SCHEMAS: Array<{ key: string; schema: any }> = SCHEMA_NAMES.map(key => ({ + key, + schema: ALL_SCHEMAS[key], +})); + +interface ErrorBoundaryProps { + children: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: string; +} + +class ErrorBoundary extends React.Component { + public static getDerivedStateFromError(error: Error) { + return { hasError: true, error: `${error.message}\n${error.stack}` }; + } + + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: '' }; + } + + public render() { + if (this.state.hasError) { + return ( +
+

Error rendering chart:

+
{this.state.error}
+
+ ); + } + + return this.props.children; + } +} + +// Categorize schemas for better organization +function categorizeSchemas(): Map { + const categories = new Map(); + + SCHEMA_NAMES.forEach(name => { + let category = 'Other'; + + // Categorization logic based on schema name patterns + const categoryKeywords: [string, string[]][] = [ + ['Financial', ['stock', 'portfolio', 'profit', 'revenue', 'cashflow', 'budget', 'expense', 'roi', 'financial', 'dividend']], + ['E-Commerce', ['orders', 'conversion', 'product', 'inventory', 'customer', 'price', 'seasonal', 'category', 'shipping', 'discount', 'sales', 'market']], + ['Marketing', ['campaign', 'engagement', 'social', 'ad', 'ctr', 'channel', 'influencer', 'viral', 'sentiment', 'impression', 'lead']], + ['Healthcare', ['patient', 'age', 'disease', 'treatment', 'hospital', 'bmi', 'recovery', 'medication', 'symptom', 'health']], + ['Education', ['test', 'grade', 'course', 'student', 'attendance', 'study', 'graduation', 'skill', 'learning', 'dropout']], + ['Manufacturing', ['production', 'defect', 'machine', 'downtime', 'quality', 'shift', 'turnover', 'supply', 'efficiency', 'maintenance']], + ['Climate', ['temperature', 'precipitation', 'co2', 'renewable', 'air', 'weather', 'sea', 'biodiversity', 'energy', 'climate']], + ['Technology', ['api', 'error', 'server', 'deployment', 'user_sessions', 'bug', 'performance', 'code', 'bandwidth', 'system', 'website', 'log_scale']], + ['Sports', ['player', 'team', 'game', 'season', 'attendance_bar', 'league', 'streaming', 'genre', 'tournament']], + ['Basic Charts', ['linechart', 'areachart', 'barchart', 'scatterchart', 'donutchart', 'heatmapchart', 'grouped_bar', 'stacked_bar', 'line_bar_combo']], + ]; + + for (const [cat, keywords] of categoryKeywords) { + if (keywords.some(keyword => name.includes(keyword))) { + category = cat; + break; + } + } + + if (!categories.has(category)) { + categories.set(category, []); + } + categories.get(category)!.push(name); + }); + + return categories; +} + +const SCHEMA_CATEGORIES = categorizeSchemas(); + +// Generate options from all schemas +const ALL_OPTIONS: Array<{ key: string; text: string; category: string }> = []; +SCHEMA_CATEGORIES.forEach((schemas, category) => { + schemas.forEach(schemaName => { + const text = schemaName + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + ALL_OPTIONS.push({ key: schemaName, text, category }); + }); +}); + +// Sort options by category and name +ALL_OPTIONS.sort((a, b) => { + if (a.category !== b.category) { + // Priority order for categories + const categoryOrder = [ + 'Basic Charts', + 'Financial', + 'E-Commerce', + 'Marketing', + 'Healthcare', + 'Education', + 'Manufacturing', + 'Climate', + 'Technology', + 'Sports', + 'Other', + ]; + return categoryOrder.indexOf(a.category) - categoryOrder.indexOf(b.category); + } + return a.text.localeCompare(b.text); +}); + +type LoadingState = 'initial' | 'loading' | 'partially_loaded' | 'loaded'; + +export const Default = (): React.ReactElement => { + const [selectedChart, setSelectedChart] = React.useState(DEFAULT_SCHEMAS[0].key); + const [schemaText, setSchemaText] = React.useState(JSON.stringify(DEFAULT_SCHEMAS[0].schema, null, 2)); + const [width, setWidth] = React.useState(600); + const [height, setHeight] = React.useState(400); + const [selectedCategory, setSelectedCategory] = React.useState('All'); + const [showMore, setShowMore] = React.useState(false); + const [loadingState, setLoadingState] = React.useState('initial'); + const loadedSchemas = React.useRef>([]); + const [loadedSchemasCount, setLoadedSchemasCount] = React.useState(0); + + // Load schemas from GitHub fluentui-charting-contrib repository + const loadSchemas = React.useCallback(async (startLoadingState: LoadingState = 'loading') => { + setLoadingState(startLoadingState); + const offset = loadedSchemas.current.length; + const promises = Array.from({ length: 100 }, (_, index) => { + const id = offset + index + 1; + const filename = `data_${id < 100 ? ('00' + id).slice(-3) : id}_vega`; + return fetch( + `https://raw.githubusercontent.com/microsoft/fluentui-charting-contrib/refs/heads/main/vega_data/${filename}.json`, + ) + .then(response => { + if (response.status === 404) { + return null; + } + return response.json(); + }) + .then(schema => { + if (!schema) { + return null; + } + return { key: filename, schema }; + }) + .catch(() => null); + }); + + const results = await Promise.all(promises); + const validResults = results.filter(item => item !== null) as Array<{ key: string; schema: any }>; + loadedSchemas.current.push(...validResults); + setLoadedSchemasCount(loadedSchemas.current.length); + + // Only disable "Load more" if we got very few results (less than 10 out of 100) + // This indicates we've reached the end of available schemas + const disableLoadMore = validResults.length < 10; + setLoadingState(disableLoadMore ? 'loaded' : 'partially_loaded'); + }, []); + + React.useEffect(() => { + if (showMore && loadedSchemasCount === 0) { + loadSchemas('initial'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showMore]); + + const getSchemaByKey = React.useCallback( + (key: string): any => { + // First check DEFAULT_SCHEMAS + const defaultSchema = DEFAULT_SCHEMAS.find(x => x.key === key); + if (defaultSchema) { + return defaultSchema.schema; + } + // Then check loaded schemas if in showMore mode + if (showMore) { + const loadedSchema = loadedSchemas.current.find(x => x.key === key); + if (loadedSchema) { + return loadedSchema.schema; + } + } + return null; + }, + [showMore], + ); + + const onSwitchDataChange = React.useCallback((ev: React.ChangeEvent) => { + setShowMore(ev.currentTarget.checked); + // Reset to first chart when switching modes + if (!ev.currentTarget.checked) { + setSelectedChart(DEFAULT_SCHEMAS[0].key); + setSchemaText(JSON.stringify(DEFAULT_SCHEMAS[0].schema, null, 2)); + // Clear loaded schemas to free memory + loadedSchemas.current = []; + setLoadedSchemasCount(0); + setLoadingState('initial'); + } + }, []); + + const handleChartChange = (_e: SelectionEvents, data: OptionOnSelectData) => { + const chartKey = data.optionValue || DEFAULT_SCHEMAS[0].key; + setSelectedChart(chartKey); + const schema = getSchemaByKey(chartKey); + setSchemaText(JSON.stringify(schema || {}, null, 2)); + }; + + const handleCategoryChange = (_e: SelectionEvents, data: OptionOnSelectData) => { + setSelectedCategory(data.optionValue || 'All'); + }; + + const handleSchemaChange = (e: React.ChangeEvent) => { + setSchemaText(e.target.value); + }; + + const handleWidthChange = (_e: React.ChangeEvent, data: InputOnChangeData) => { + const value = parseInt(data.value, 10); + if (!isNaN(value) && value > 0) { + setWidth(value); + } + }; + + const handleHeightChange = (_e: React.ChangeEvent, data: InputOnChangeData) => { + const value = parseInt(data.value, 10); + if (!isNaN(value) && value > 0) { + setHeight(value); + } + }; + + let parsedSchema: any; + let parseError: string | null = null; + try { + parsedSchema = JSON.parse(schemaText); + } catch (e: any) { + parsedSchema = null; + parseError = e.message; + } + + // Generate options from available schemas + const currentOptions = React.useMemo(() => { + const schemas = showMore ? [...DEFAULT_SCHEMAS, ...loadedSchemas.current] : DEFAULT_SCHEMAS; + return schemas.map(item => { + const text = item.key + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + return { key: item.key, text, category: 'All' }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showMore, loadedSchemasCount]); + + const filteredOptions = currentOptions; + + const schemaCount = showMore ? DEFAULT_SCHEMAS.length + loadedSchemasCount : DEFAULT_SCHEMAS.length; + + const categories = React.useMemo(() => { + // In "show few" mode, only show "All" category + if (!showMore) { + return ['All']; + } + // In "show more" mode, show all categories + return ['All', ...Array.from(SCHEMA_CATEGORIES.keys())].sort((a, b) => { + if (a === 'All') { + return -1; + } + if (b === 'All') { + return 1; + } + const categoryOrder = [ + 'Basic Charts', + 'Financial', + 'E-Commerce', + 'Marketing', + 'Healthcare', + 'Education', + 'Manufacturing', + 'Climate', + 'Technology', + 'Sports', + 'Other', + ]; + return categoryOrder.indexOf(a) - categoryOrder.indexOf(b); + }); + }, [showMore]); + + return ( +
+

Vega-Lite Declarative Chart - {schemaCount} Schemas

+

+ This component renders charts from Vega-Lite specifications. Browse through {schemaCount} + {showMore ? ' chart examples (including additional schemas from GitHub)' : ' chart examples'}. + {showMore + ? ' Use "Load more" to load additional schemas from the fluentui-charting-contrib repository.' + : ' Enable "Show more" to load thousands of additional examples from GitHub.'} +

+ +
+ +
+ +
+ + + {categories.map(category => ( + + ))} + + + + + opt.key === selectedChart)?.text || filteredOptions[0]?.text || 'Chart'} + onOptionSelect={handleChartChange} + style={{ width: '300px' }} + > + {filteredOptions.map(option => ( + + ))} + + + + + + + + + + + + {showMore && ( +
+ +
+ )} +
+ +
+
+ +