Audience: Python backend / binder developers producing MAIDR JSON for violin plots. Branch:
violin_plot_maidrLast updated: 2026-03-03
- Overview
- Top-Level MAIDR Structure
- Layer Configuration (MaidrLayer)
- KDE Layer —
violin_kde - Box Layer —
violin_box - ViolinOptions — Conditional Display
- SVG Selector Contract
- Navigation Behavior
- Audio Behavior
- Text Output (Screen Reader)
- Braille Output
- Layer Switching
- Orientation Support
- Complete JSON Example
- Field Reference Tables
- Checklist for Backend Implementation
A violin plot in MAIDR is represented as two layers inside a single subplot:
| Layer | type value |
Purpose | Trace Class |
|---|---|---|---|
| Box overlay | "violin_box" |
Summary statistics (min, Q1, median, Q3, max, mean, outliers) | ViolinBoxTrace |
| KDE curve | "violin_kde" |
The density (kernel density estimate) outline of each violin | ViolinKdeTrace |
Both layers share the same subplot. The user navigates within one layer at a time and can switch between them with PageUp/PageDown.
The first layer in the layers array is the default view shown when the user enters the plot. The frontend does not reorder layers — it uses the JSON array order directly.
Recommended order: Put violin_box first so users start with the box plot summary, then switch to violin_kde for density details. This gives users the statistical overview first before exploring the full density curve.
| Aspect | Old (main branch) | New (this branch) |
|---|---|---|
| Trace types | "smooth" + "box" with heuristic detection (BOX+SMOOTH in same subplot = violin) |
Explicit "violin_kde" + "violin_box" types |
| Detection | Structural heuristic in Subplot constructor | Direct type matching in TraceFactory |
| Box sections | Standard box used Q1/Q3 always; violin box skipped them | ViolinBoxTrace always includes Q1/Q3, conditionally includes Q2/MEAN |
| Data format | SmoothPoint ({x, y, svg_x, svg_y}) |
ViolinKdePoint ({x, y, density?, width?, svg_x?, svg_y?}) |
| Factory | isViolinPlot flag passed to factory |
No flag needed — type string drives creation |
{
"id": "unique-chart-id",
"title": "Diamond Price Distribution by Cut Quality",
"subplots": [
[
{
"layers": [
{ "type": "violin_box", ... },
{ "type": "violin_kde", ... }
]
}
]
]
}Rules:
- Both layers MUST be in the same subplot
layersarray. - Put
violin_boxfirst so it is the default layer shown to the user. - Layer order determines default view — the frontend uses
typeto dispatch, not position. - The
idat the root level is used for DOM element identification.
Each layer object has these fields:
{
"id": string, // Unique layer ID
"type": "violin_kde" | "violin_box",
"title"?: string, // Optional plot title
"orientation"?: "vert" | "horz", // Default: "vert"
"axes"?: {
"x"?: string, // X-axis label (e.g., "Cut Quality")
"y"?: string, // Y-axis label (e.g., "Price (USD)")
"fill"?: string, // Fill axis label (rarely used for violin)
"format"?: { ... } // Optional value formatting config
},
"selectors"?: ..., // SVG CSS selectors (see Section 7)
"violinOptions"?: { ... }, // Only for violin_box (see Section 6)
"data": ... // Layer-specific data (see Sections 4 & 5)
}The data field is a 2D array: ViolinKdePoint[][]
data[violinIndex][curvePosition] = ViolinKdePoint
- Outer array: One sub-array per violin group (e.g., 5 violins = 5 sub-arrays).
- Inner array: Points along the KDE curve for that violin, ordered from bottom to top of the curve.
{
"x": string | number, // REQUIRED — Categorical label (e.g., "Ideal", "Premium")
"y": number, // REQUIRED — Position along density axis (numeric)
"density"?: number, // OPTIONAL — KDE density value at this point
"width"?: number, // OPTIONAL — Half-width of violin (density fallback)
"svg_x"?: number, // OPTIONAL — SVG viewport X coordinate for highlighting
"svg_y"?: number // OPTIONAL — SVG viewport Y coordinate for highlighting
}| Field | Required | Fallback | Notes |
|---|---|---|---|
x |
YES | — | Same value for all points in a violin. Used for text labels. |
y |
YES | — | Monotonically increasing along the curve (bottom to top). |
density |
NO | width |
Primary density value. If absent, width is used. |
width |
NO | 0 |
Used only when density is absent. Legacy format support. |
svg_x |
NO | Skip highlight | If missing or NaN, no highlight circle is created for that point. |
svg_y |
NO | Skip highlight | Must be paired with svg_x for highlighting to work. |
Density Resolution Chain:
effectiveDensity = point.density ?? point.width ?? 0
The data typically has paired left/right sides of the violin at each Y level (e.g., 198 points for a 99-Y-level grid):
Index 0: { x: "Ideal", y: -501.7, svg_x: 100.41, svg_y: 281.84, width: 0.044 } // LEFT side
Index 1: { x: "Ideal", y: -501.7, svg_x: 103.84, svg_y: 281.84, width: 0.044 } // RIGHT side
Index 2: { x: "Ideal", y: -294.2, svg_x: 98.25, svg_y: 279.41, width: 0.100 } // LEFT side
Index 3: { x: "Ideal", y: -294.2, svg_x: 105.99, svg_y: 279.41, width: 0.100 } // RIGHT side
...
IMPORTANT: Do NOT deduplicate these pairs. The navigation traverses both sides of the curve (up the left side, down the right side). Deduplicating would break navigation — the user would only traverse one side.
{
"id": "kde-layer-1",
"type": "violin_kde",
"title": "Diamond Price Distribution by Cut Quality",
"axes": {
"x": "Cut Quality",
"y": "Price (USD)"
},
"selectors": [
"g.violins > g:nth-child(1)",
"g.violins > g:nth-child(2)",
"g.violins > g:nth-child(3)"
],
"data": [
[
{ "x": "Ideal", "y": -501.7, "svg_x": 100.41, "svg_y": 281.84, "width": 0.044 },
{ "x": "Ideal", "y": -501.7, "svg_x": 103.84, "svg_y": 281.84, "width": 0.044 },
{ "x": "Ideal", "y": -294.2, "svg_x": 98.25, "svg_y": 279.41, "width": 0.100 },
{ "x": "Ideal", "y": -294.2, "svg_x": 105.99, "svg_y": 279.41, "width": 0.100 }
],
[
{ "x": "Premium", "y": -400.0, "svg_x": 200.0, "svg_y": 270.0, "width": 0.035 },
{ "x": "Premium", "y": -400.0, "svg_x": 205.0, "svg_y": 270.0, "width": 0.035 }
]
]
}Selectors can be:
- One per violin:
string[]withlength === data.length - Single pattern:
string[]withlength === 1(matched against all violins)
The frontend resolves SVG elements from each selector, preferring <use> reference elements and falling back to <path> geometry elements. Circle highlight elements are then created at (svg_x, svg_y) positions.
The data field is a 1D array: BoxPoint[]
data[violinIndex] = BoxPoint
One BoxPoint per violin group.
{
"fill": string, // REQUIRED — Category label (e.g., "Ideal")
"lowerOutliers": number[], // REQUIRED — Lower outlier values (can be [])
"min": number, // REQUIRED — Minimum (whisker bottom)
"q1": number, // REQUIRED — 25th percentile
"q2": number, // REQUIRED — 50th percentile (median)
"q3": number, // REQUIRED — 75th percentile
"max": number, // REQUIRED — Maximum (whisker top)
"upperOutliers": number[], // REQUIRED — Upper outlier values (can be [])
"mean"?: number // OPTIONAL — Mean value
}| Field | Required | Type | Notes |
|---|---|---|---|
fill |
YES | string |
Categorical label. Displayed in text mode as main axis value. |
lowerOutliers |
YES | number[] |
Can be empty []. Each value is an outlier below the whisker. |
min |
YES | number |
Minimum value (lower whisker endpoint). |
q1 |
YES | number |
First quartile (25th percentile). |
q2 |
YES | number |
Median (50th percentile). |
q3 |
YES | number |
Third quartile (75th percentile). |
max |
YES | number |
Maximum value (upper whisker endpoint). |
upperOutliers |
YES | number[] |
Can be empty []. Each value is an outlier above the whisker. |
mean |
NO | number |
Only needed if violinOptions.showMean is true. Frontend handles missing/NaN gracefully. |
{
"id": "box-layer-1",
"type": "violin_box",
"axes": {
"x": "Cut Quality",
"y": "Price (USD)"
},
"violinOptions": {
"showMedian": true,
"showMean": false,
"showExtrema": true
},
"selectors": [
{
"lowerOutliers": ["g.box1 circle.lower-outlier"],
"min": "g.box1 line.whisker-min",
"iq": "g.box1 rect.iqr-box",
"q2": "g.box1 line.median",
"max": "g.box1 line.whisker-max",
"upperOutliers": ["g.box1 circle.upper-outlier"],
"mean": "g.box1 line.mean"
},
{
"lowerOutliers": [],
"min": "g.box2 line.whisker-min",
"iq": "g.box2 rect.iqr-box",
"q2": "g.box2 line.median",
"max": "g.box2 line.whisker-max",
"upperOutliers": ["g.box2 circle.upper-outlier"]
}
],
"data": [
{
"fill": "Ideal",
"lowerOutliers": [18806],
"min": 326,
"q1": 878,
"q2": 1810,
"q3": 4678,
"max": 18806,
"upperOutliers": []
},
{
"fill": "Premium",
"lowerOutliers": [],
"min": 326,
"q1": 1046,
"q2": 3185,
"q3": 6296,
"max": 18823,
"upperOutliers": []
}
]
}The violinOptions field on the violin_box layer controls which summary statistics are navigable.
{
"showMedian"?: boolean, // Default: true — Include Q2 (50%) section
"showMean"?: boolean, // Default: false — Include Mean section
"showExtrema"?: boolean, // Default: true — Include MIN and MAX sections
"showOutliers"?: boolean // Default: true — Include outlier sections
}The navigable sections are built in this fixed order, with conditional items skipped:
| # | Section | Label | Condition | Always? |
|---|---|---|---|---|
| 0 | LOWER_OUTLIER | "Lower outlier(s)" |
— | YES |
| 1 | MIN | "Minimum" |
— | YES |
| 2 | Q1 | "25%" |
— | YES |
| 3 | Q2 | "50%" |
showMedian !== false |
Default YES |
| 4 | MEAN | "Mean" |
showMean === true |
Default NO |
| 5 | Q3 | "75%" |
— | YES |
| 6 | MAX | "Maximum" |
showExtrema !== false |
Default YES |
| 7 | UPPER_OUTLIER | "Upper outlier(s)" |
— | YES |
Notes:
- Q1 and Q3 are ALWAYS included (core quartile statistics).
- MEAN is only included when explicitly enabled (
showMean: true). - Section indices shift when optional sections are excluded.
- The MIN section index is cached internally for navigation reset.
If violinOptions is not provided at all, the default {} is used, which means:
showMediandefaults totrue— Q2 includedshowMeandefaults tofalse— MEAN excludedshowExtremadefaults totrue— MIN/MAX includedshowOutliersdefaults totrue— Outlier sections included
Resulting default section order:
LOWER_OUTLIER -> MIN -> Q1 -> Q2 -> Q3 -> MAX -> UPPER_OUTLIER (7 sections)
Type: string | string[]
| Format | Meaning |
|---|---|
string[] with length === data.length |
One CSS selector per violin |
string[] with length === 1 |
Single pattern matched for all violins |
string |
Single selector string |
Element Resolution:
- Query DOM with selector
- Prefer
<use>reference elements - Fallback to
<path>geometry elements - Create
<circle>highlight elements at each point's(svg_x, svg_y)
Type: BoxSelector[] with length === data.length (one per violin)
{
"lowerOutliers": string[], // CSS selectors for outlier circles below whisker
"min": string, // CSS selector for minimum whisker line
"iq": string, // CSS selector for IQ box rectangle (Q1-Q3 range)
"q2": string, // CSS selector for median line
"max": string, // CSS selector for maximum whisker line
"upperOutliers": string[], // CSS selectors for outlier circles above whisker
"mean"?: string // CSS selector for mean marker (only if showMean)
}Critical: The iq field
The IQ box (iq) is a <rect> SVG element spanning Q1 to Q3. The frontend extracts Q1/Q3 highlight positions from it:
- Vertical orientation: Q1 = bottom edge, Q3 = top edge
- Horizontal orientation: Q1 = left edge, Q3 = right edge
The frontend calls Svg.createLineElement(iqElement, 'bottom') and Svg.createLineElement(iqElement, 'top') to create line elements at the edges.
Selector Array Requirements:
lowerOutliersandupperOutlierscan be empty arrays[]when no outliers exist.meanis optional — only required ifviolinOptions.showMeanistrue.- All other fields are required strings (CSS selectors).
| Key | Direction | Action |
|---|---|---|
| Left | BACKWARD | Move to previous violin. Reset to bottom of curve (col=0). |
| Right | FORWARD | Move to next violin. Reset to bottom of curve (col=0). |
| Up | UPWARD | Move to next point along KDE curve (col+1). |
| Down | DOWNWARD | Move to previous point along KDE curve (col-1). |
| Ctrl+Left | BACKWARD extreme | Jump to first violin. |
| Ctrl+Right | FORWARD extreme | Jump to last violin. |
| Ctrl+Up | UPWARD extreme | Jump to top of curve. |
| Ctrl+Down | DOWNWARD extreme | Jump to bottom of curve. |
| PageUp | — | Switch to violin_box layer (preserves position). |
| PageDown | — | Switch to violin_box layer (preserves position). |
Boundary behavior: At boundaries, the user hears an out-of-bounds notification and position does not change.
Vertical orientation (default):
| Key | Direction | Action |
|---|---|---|
| Left | BACKWARD | Move to previous violin. Reset to MIN section. |
| Right | FORWARD | Move to next violin. Reset to MIN section. |
| Up | UPWARD | Move to next section (e.g., MIN -> Q1 -> Q2 -> ...). |
| Down | DOWNWARD | Move to previous section (e.g., Q2 -> Q1 -> MIN -> ...). |
| PageUp/Down | — | Switch to violin_kde layer (preserves position). |
Horizontal orientation:
| Key | Direction | Action |
|---|---|---|
| Up | UPWARD | Move to next violin. Reset to MIN section. |
| Down | DOWNWARD | Move to previous violin. Reset to MIN section. |
| Left | BACKWARD | Move to previous section. |
| Right | FORWARD | Move to next section. |
Initial entry: When first entering the box layer, navigation starts at the MIN section (not LOWER_OUTLIER).
Pitch (frequency):
- Based on reference violin (first violin, index 0) density values.
- All violins use the same pitch scale so relative density is comparable.
- Provides
[prevDensity, currentDensity, nextDensity]as raw frequency array for smooth transitions. - Min/max frequency derived from reference violin's positive density values.
Volume:
- Based on current violin's own density, normalized per-violin.
volumeScale = currentDensity / maxDensityOfCurrentViolin- Louder at wider parts of the violin, quieter at narrow parts.
Panning:
- Horizontal position reflects which violin (left=first, right=last).
- Vertical position reflects position along curve.
Continuous mode: isContinuous: true — audio plays as a smooth tone, not discrete beeps.
Pitch (frequency):
min/maxderived from global min/max of all box values across all violins (NaN excluded).rawis the current section value (single number or array for outliers).
Panning:
- Orientation-dependent mapping of violin index and section value.
Verbose mode:
Cut Quality is Ideal, Price (USD) is 1810.5, volume is 0.8000
Format: {xAxisLabel} is {categoryLabel}, {yAxisLabel} is {yValue}, volume is {densityValue}
categoryLabel: Frompoint.xfieldyValue: Rounded to 4 decimal placesdensityValue: Shown as "volume", rounded to 4 decimal places. Only shown if density > 0.
Terse mode:
Ideal, 1810.5, 0.8000
Verbose mode:
Cut Quality is Ideal, 25% Price (USD) is 878
Format: {mainAxisLabel} is {categoryLabel}, {sectionName} {crossAxisLabel} is {value}
categoryLabel: FromBoxPoint.fillsectionName: One of:"Lower outlier(s)","Minimum","25%","50%","Mean","75%","Maximum","Upper outlier(s)"value: The numeric value for that section
Terse mode:
Ideal, 25% 878
When switching layers:
Layer 1 of 2: violin box plot at Cut Quality is Ideal
Layer 2 of 2: violin kde plot at Cut Quality is Ideal
The cross value (Y) is excluded for violin box layers during layer switch announcements to avoid confusion.
Provides density values for braille rendering:
values: 2D array of density valuesnumber[][]min/max: Per-violin minimum/maximum density (arrays)- Row = current violin, Col = current curve position
Provides BoxPoint array:
values: Array ofBoxPointobjectsmin/max: Global min/max across all box values- Row/Col adjusted for orientation
- Frontend reads current violin index (X value) from KDE layer.
- Box layer receives the violin index via
moveToXValue(). - Box layer sets position to that violin, resets to MIN section.
- If the KDE layer also provides a Y value, box layer finds the closest section matching that Y value via
moveToXAndYValue().
- Frontend reads current violin index (X value) from box layer.
- KDE layer receives the violin index via
onSwitchFrom(). - KDE layer sets position to that violin.
- If the box layer provides a Y value, KDE layer finds the closest Y position on the curve.
- Falls back to bottom of curve (col=0) if Y matching fails.
Key guarantee: The violin group (categorical position) is always preserved during layer switching.
| Orientation | Value | KDE: violins on | KDE: curve along | Box: violins on | Box: sections along |
|---|---|---|---|---|---|
| Vertical | "vert" |
X-axis (horizontal) | Y-axis (vertical) | X-axis | Y-axis |
| Horizontal | "horz" |
Y-axis (vertical) | X-axis (horizontal) | Y-axis | X-axis |
Default: "vert" if not specified.
Backend note for horizontal orientation:
- The frontend reverses the point array for horizontal box plots to match visual order (lower-left start).
- Data should still be provided in natural order; the frontend handles the visual mapping.
{
"id": "violin-plot-001",
"title": "Diamond Price Distribution by Cut Quality",
"subplots": [
[
{
"layers": [
{
"id": "box-layer",
"type": "violin_box",
"axes": {
"x": "Cut Quality",
"y": "Price (USD)"
},
"violinOptions": {
"showMedian": true,
"showMean": false,
"showExtrema": true
},
"selectors": [
{
"lowerOutliers": [],
"min": "#box1 .whisker-min",
"iq": "#box1 .iqr-rect",
"q2": "#box1 .median-line",
"max": "#box1 .whisker-max",
"upperOutliers": ["#box1 .outlier-1", "#box1 .outlier-2"]
},
{
"lowerOutliers": [],
"min": "#box2 .whisker-min",
"iq": "#box2 .iqr-rect",
"q2": "#box2 .median-line",
"max": "#box2 .whisker-max",
"upperOutliers": []
},
{
"lowerOutliers": [],
"min": "#box3 .whisker-min",
"iq": "#box3 .iqr-rect",
"q2": "#box3 .median-line",
"max": "#box3 .whisker-max",
"upperOutliers": []
}
],
"data": [
{
"fill": "Ideal",
"lowerOutliers": [],
"min": 326,
"q1": 878,
"q2": 1810,
"q3": 4678,
"max": 18806,
"upperOutliers": [18806]
},
{
"fill": "Premium",
"lowerOutliers": [],
"min": 326,
"q1": 1046,
"q2": 3185,
"q3": 6296,
"max": 18823,
"upperOutliers": []
},
{
"fill": "Good",
"lowerOutliers": [],
"min": 327,
"q1": 1145,
"q2": 3050,
"q3": 5028,
"max": 18788,
"upperOutliers": []
}
]
},
{
"id": "kde-layer",
"type": "violin_kde",
"title": "Diamond Price Distribution by Cut Quality",
"axes": {
"x": "Cut Quality",
"y": "Price (USD)"
},
"selectors": [
"#violin-group-1 path",
"#violin-group-2 path",
"#violin-group-3 path"
],
"data": [
[
{"x": "Ideal", "y": -501.7, "svg_x": 100.4, "svg_y": 281.8, "width": 0.044},
{"x": "Ideal", "y": -501.7, "svg_x": 103.8, "svg_y": 281.8, "width": 0.044},
{"x": "Ideal", "y": -294.2, "svg_x": 98.3, "svg_y": 279.4, "width": 0.100},
{"x": "Ideal", "y": -294.2, "svg_x": 106.0, "svg_y": 279.4, "width": 0.100}
],
[
{"x": "Premium", "y": -400.0, "svg_x": 200.0, "svg_y": 270.0, "width": 0.035},
{"x": "Premium", "y": -400.0, "svg_x": 205.0, "svg_y": 270.0, "width": 0.035}
],
[
{"x": "Good", "y": -350.0, "svg_x": 300.0, "svg_y": 275.0, "width": 0.040},
{"x": "Good", "y": -350.0, "svg_x": 304.0, "svg_y": 275.0, "width": 0.040}
]
]
}
]
}
]
]
}| Field | Type | Required | Default/Fallback | Description |
|---|---|---|---|---|
x |
string | number |
YES | — | Categorical label for the violin group |
y |
number |
YES | — | Numeric position along the density axis |
density |
number |
NO | Falls back to width |
KDE density estimation value |
width |
number |
NO | Falls back to 0 |
Half-width of violin (legacy format) |
svg_x |
number |
NO | Highlight skipped | SVG viewport X coordinate |
svg_y |
number |
NO | Highlight skipped | SVG viewport Y coordinate |
| Field | Type | Required | Default/Fallback | Description |
|---|---|---|---|---|
fill |
string |
YES | — | Category label (displayed in text) |
lowerOutliers |
number[] |
YES | — | Lower outlier values (can be []) |
min |
number |
YES | — | Minimum (lower whisker) |
q1 |
number |
YES | — | 25th percentile |
q2 |
number |
YES | — | 50th percentile (median) |
q3 |
number |
YES | — | 75th percentile |
max |
number |
YES | — | Maximum (upper whisker) |
upperOutliers |
number[] |
YES | — | Upper outlier values (can be []) |
mean |
number |
NO | NaN (handled) |
Mean value (only if showMean: true) |
| Field | Type | Required | Default | Effect |
|---|---|---|---|---|
showMedian |
boolean |
NO | true |
Include Q2 (50%) in navigable sections |
showMean |
boolean |
NO | false |
Include Mean in navigable sections |
showExtrema |
boolean |
NO | true |
Include MIN and MAX in navigable sections |
showOutliers |
boolean |
NO | true |
Include outlier sections |
| Field | Type | Required | Description |
|---|---|---|---|
lowerOutliers |
string[] |
YES | CSS selectors for lower outlier circles (can be []) |
min |
string |
YES | CSS selector for minimum whisker line |
iq |
string |
YES | CSS selector for IQ box rectangle (Q1-Q3 range) |
q2 |
string |
YES | CSS selector for median line |
max |
string |
YES | CSS selector for maximum whisker line |
upperOutliers |
string[] |
YES | CSS selectors for upper outlier circles (can be []) |
mean |
string |
NO | CSS selector for mean marker (only if showMean: true) |
| Enum | String Value | Description |
|---|---|---|
VIOLIN_KDE |
"violin_kde" |
KDE density curve layer |
VIOLIN_BOX |
"violin_box" |
Box summary statistics layer |
| Enum | String Value | Description |
|---|---|---|
VERTICAL |
"vert" |
Violins arranged horizontally, curves vertical (default) |
HORIZONTAL |
"horz" |
Violins arranged vertically, curves horizontal |
- Set layer
typeto"violin_box"(NOT"box") - Set layer
typeto"violin_kde"(NOT"smooth") - Put
violin_boxlayer first in thelayersarray (default view for users) - KDE data is
ViolinKdePoint[][]— outer array per violin, inner array per curve point - KDE points include BOTH sides of violin (paired left/right at each Y level) — do NOT deduplicate
- KDE
xfield has the categorical label for every point in a violin - KDE
yfield has numeric values, monotonically increasing along the curve - KDE
densityorwidthfield has the density value (at least one must be present) - KDE
svg_x/svg_yfields have SVG viewport coordinates for highlighting (recommended) - Box data is
BoxPoint[]— one per violin - Box
fillfield matches the categorical label from KDExfield - Box includes all standard fields:
lowerOutliers,min,q1,q2,q3,max,upperOutliers - Box
meanfield included only whenviolinOptions.showMeanistrue - Outlier arrays can be empty
[]but MUST be present
- Both layers in the same subplot
layersarray -
violinOptionsset on theviolin_boxlayer (not theviolin_kdelayer) -
orientationconsistent between both layers (both"vert"or both"horz") -
axeslabels consistent between both layers
- KDE selectors: one CSS selector per violin OR one single pattern
- Box selectors:
BoxSelector[]with one entry per violin - Box
iqselector points to the IQ box<rect>element (used to derive Q1/Q3 highlight) - Box
meanselector provided only whenshowMean: true - Outlier selector arrays can be empty but must be present
- Number of KDE sub-arrays === number of BoxPoints === number of BoxSelectors
- All violins have the same number of curve points (recommended for consistent navigation)
- All BoxPoint numeric fields are valid numbers (not
nullorundefined) - SVG selectors resolve to actual elements in the rendered SVG DOM