# SVG charts in tables > Draw a box plot, a progress bar or a line chart in every row of a table by building SVG with AQL user-defined functions. This template draws a small chart in each row of a table. A metric returns an SVG string, and the table cell renders it. - [Box Plot](./box-plot.md): the spread of a measure per row. - [Progress Bar](./progress-bar.md): a value against a target per row. - [Line Chart](./line-chart.md): the trend of a measure over time per row. All charts use the same function library, so set the library up once on this page. ## How it works Each chart is built from layers of small functions. Each layer calls the one below it. | Module | What it does | |---|---| | `SVG.Viz` | The chart you call from a metric. Wraps a `Compound` function in an `` element. | | `SVG.Compound` | The chart body: the shapes and the layout math. | | `SVG.Element` | One SVG shape each: `Rect`, `Circle`, `Line`, `Polyline`. | | `SVG.Attr` | Builds the attribute string for a shape, such as fill and stroke. | | `SVG.Scale` | Maps a data value to a pixel position. | | `SVG.Axis` | Per-row statistics for the box plot and line chart: min, max, percentile and count. | | `SVG.Html` | Pins the chart to a fixed size inside the table cell. | The library is a port of the [DaxLib](https://github.com/daxlib/daxlib) `daxlib.svg` package (MIT) to AQL. ## Step 1: Add the function library Add this `AqlFunctionModule` to your project. It only needs to exist once. ```aml title="library/aql-functions/svg.aml" AqlFunctionModule SVG { // Wraps shapes in an element. function SVG { param pWidth { type: 'text' } param pHeight { type: 'text' } param pViewbox { type: 'text' } param pContents { type: 'text' } param pSortValue { type: 'number' } return_type: 'text' definition: @aql concat( "", case(when: pSortValue is not null, then: concat('', cast(pSortValue, 'text'), ''), else: ''), pContents, "" ) ;; } module Color: AqlFunctionModule { // Palette lookup by theme name and variant. function Theme { param pThemeName { type: 'text' } param pVariant { type: 'number' } return_type: 'text' definition: @aql case( when: pThemeName == 'Power BI' and pVariant == 1, then: '#118DFF', when: pThemeName == 'Power BI' and pVariant == 2, then: '#12239E', when: pThemeName == 'Power BI' and pVariant == 3, then: '#E66C37', when: pThemeName == 'Power BI' and pVariant == 4, then: '#6B007B', when: pThemeName == 'Power BI' and pVariant == 5, then: '#E044A7', when: pThemeName == 'Power BI' and pVariant == 6, then: '#744EC2', when: pThemeName == 'Power BI' and pVariant == 7, then: '#D9B300', when: pThemeName == 'Power BI' and pVariant == 8, then: '#D64550', else: '#000000' ) ;; } } module Attr: AqlFunctionModule { // Builds name='value' when the value is not null, and an empty string otherwise. function Opt { param pName { type: 'text' } param pValue { type: 'text' } return_type: 'text' definition: @aql case(when: pValue is not null, then: concat(pName, "='", pValue, "' "), else: '') ;; } // Same as Opt, for a number. function OptNum { param pName { type: 'text' } param pValue { type: 'number' } return_type: 'text' definition: @aql case(when: pValue is not null, then: concat(pName, "='", cast(pValue, 'text'), "' "), else: '') ;; } // fill, fill-opacity, fill-rule, stroke, stroke-width, stroke-opacity, opacity function Shapes { param pFill { type: 'text' } param pFillOpacity { type: 'number' } param pFillRule { type: 'text' } param pStroke { type: 'text' } param pStrokeWidth { type: 'number' } param pStrokeOpacity { type: 'number' } param pOpacity { type: 'number' } return_type: 'text' definition: @aql concat( Opt ('fill', pFill), OptNum('fill-opacity', pFillOpacity), Opt ('fill-rule', pFillRule), Opt ('stroke', pStroke), OptNum('stroke-width', pStrokeWidth), OptNum('stroke-opacity', pStrokeOpacity), OptNum('opacity', pOpacity) ) ;; } } module Element: AqlFunctionModule { function Rect { param pX { type: 'text' } param pY { type: 'text' } param pWidth { type: 'text' } param pHeight { type: 'text' } param pRx { type: 'text' } param pRy { type: 'text' } param pAttributes { type: 'text' } param pTransforms { type: 'text' } return_type: 'text' definition: @aql concat( '' ) ;; } function Circle { param pCx { type: 'text' } param pCy { type: 'text' } param pR { type: 'text' } param pAttributes { type: 'text' } param pTransforms { type: 'text' } return_type: 'text' definition: @aql concat( '' ) ;; } function Line { param pX1 { type: 'text' } param pY1 { type: 'text' } param pX2 { type: 'text' } param pY2 { type: 'text' } param pAttributes { type: 'text' } param pTransforms { type: 'text' } return_type: 'text' definition: @aql concat( '' ) ;; } // A polyline through space-separated "x,y" points. function Polyline { param pPoints { type: 'text' } param pAttributes { type: 'text' } param pTransforms { type: 'text' } return_type: 'text' definition: @aql concat( "' ) ;; } } module Scale: AqlFunctionModule { // Linear map of a value from one range to another. function Normalize { param pInputValue { type: 'number' } param pFromMin { type: 'number' } param pFromMax { type: 'number' } param pToMin { type: 'number' } param pToMax { type: 'number' } return_type: 'number' definition: @aql pToMin + ((pInputValue - pFromMin) / (pFromMax - pFromMin)) * (pToMax - pToMin) ;; } } module Compound: AqlFunctionModule { // Layout helpers. Offset is the padded start of an axis, Plot is its padded length. function Offset { param pCoord { type: 'number' } param pSize { type: 'number' } param pPad { type: 'number' } return_type: 'number' definition: @aql pCoord + (pSize * (coalesce(pPad, 0) / 2)) ;; } function Plot { param pSize { type: 'number' } param pPad { type: 'number' } return_type: 'number' definition: @aql pSize * (1 - coalesce(pPad, 0)) ;; } // Progress bar helpers. function Orient { param p { type: 'text' } return_type: 'text' definition: @aql case(when: p == 'Vertical', then: 'Vertical', else: 'Horizontal') ;; } function MaxRef { param pVal { type: 'number' mode: 'expr' } param pTrack { type: 'number' mode: 'expr' } return_type: 'number' definition: @aql case( when: pTrack is null, then: case(when: abs(pVal) > 1, then: abs(pVal), else: 1), else: case(when: abs(pTrack) > 1, then: abs(pTrack), else: 1) ) ;; } function Pct { param pVal { type: 'number' mode: 'expr' } param pMax { type: 'number' mode: 'expr' } return_type: 'number' definition: @aql case( when: coalesce(safe_divide(pVal, pMax), 0) > 1, then: 1, when: coalesce(safe_divide(pVal, pMax), 0) < 0, then: 0, else: coalesce(safe_divide(pVal, pMax), 0) ) ;; } function ProgressBar { param pX { type: 'number' } param pY { type: 'number' } param pWidth { type: 'number' } param pHeight { type: 'number' } param pPaddingX { type: 'number' } param pPaddingY { type: 'number' } param pValueRef { type: 'number' mode: 'expr' } param pTrackRef { type: 'number' mode: 'expr' } param pFillColor { type: 'text' } param pTrackColor { type: 'text' } param pOrientation { type: 'text' } return_type: 'text' definition: @aql case( when: pValueRef is not null and Plot(pWidth, pPaddingX) > 0 and Plot(pHeight, pPaddingY) > 0, then: concat( Element.Rect( cast(Offset(pX, pWidth, pPaddingX), 'text'), cast(Offset(pY, pHeight, pPaddingY), 'text'), cast(Plot(pWidth, pPaddingX), 'text'), cast(Plot(pHeight, pPaddingY), 'text'), '3', '3', Attr.Shapes( coalesce(pTrackColor, '#E1DFDD'), 1, null, coalesce(pTrackColor, '#E1DFDD'), 1, null, null ), null ), Element.Rect( cast(Offset(pX, pWidth, pPaddingX), 'text'), cast( case( when: Orient(pOrientation) == 'Horizontal', then: Offset(pY, pHeight, pPaddingY), else: Offset(pY, pHeight, pPaddingY) + Plot(pHeight, pPaddingY) * (1 - Pct(pValueRef, MaxRef(pValueRef, pTrackRef))) ), 'text' ), cast( case( when: Orient(pOrientation) == 'Horizontal', then: Plot(pWidth, pPaddingX) * Pct(pValueRef, MaxRef(pValueRef, pTrackRef)), else: Plot(pWidth, pPaddingX) ), 'text' ), cast( case( when: Orient(pOrientation) == 'Horizontal', then: Plot(pHeight, pPaddingY), else: Plot(pHeight, pPaddingY) * Pct(pValueRef, MaxRef(pValueRef, pTrackRef)) ), 'text' ), '3', '3', Attr.Shapes( coalesce(pFillColor, '#0F6CBD'), 0.95, null, coalesce(pFillColor, '#0F6CBD'), 1, null, null ), null ) ), else: '' ) ;; } function Boxplot { param pX { type: 'number' } param pY { type: 'number' } param pWidth { type: 'number' } param pHeight { type: 'number' } param pPaddingX { type: 'number' } param pPaddingY { type: 'number' } param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } param pFillColor { type: 'text' } param pStrokeColor { type: 'text' } return_type: 'text' definition: @aql const lo = Offset(pX, pWidth, pPaddingX); const hi = Offset(pX, pWidth, pPaddingX) + Plot(pWidth, pPaddingX); const center_y = Offset(pY, pHeight, pPaddingY) + Plot(pHeight, pPaddingY) * 0.5; const cap_top = Offset(pY, pHeight, pPaddingY) + Plot(pHeight, pPaddingY) * 0.2; const cap_bot = Offset(pY, pHeight, pPaddingY) + Plot(pHeight, pPaddingY) * 0.8; const box_h = Plot(pHeight, pPaddingY) * 0.6; const axis_min = Axis.Min(pAxisModel, pAxisRef, pMeasureRef); const axis_max = Axis.Max(pAxisModel, pAxisRef, pMeasureRef); const p25 = Axis.Percentile(pAxisModel, pAxisRef, pMeasureRef, 0.25); const p50 = Axis.Percentile(pAxisModel, pAxisRef, pMeasureRef, 0.5); const p75 = Axis.Percentile(pAxisModel, pAxisRef, pMeasureRef, 0.75); const iqr = p75 - p25; const lower_whisker_value = case(when: axis_min > (p25 - 1.5 * iqr), then: axis_min, else: p25 - 1.5 * iqr); const upper_whisker_value = case(when: axis_max < (p75 + 1.5 * iqr), then: axis_max, else: p75 + 1.5 * iqr); const q1_x = Scale.Normalize(p25, axis_min, axis_max, lo, hi); const q3_x = Scale.Normalize(p75, axis_min, axis_max, lo, hi); const median_x = Scale.Normalize(p50, axis_min, axis_max, lo, hi); const lower_whisker_x = Scale.Normalize(lower_whisker_value, axis_min, axis_max, lo, hi); const upper_whisker_x = Scale.Normalize(upper_whisker_value, axis_min, axis_max, lo, hi); concat( Element.Line(cast(lower_whisker_x, 'text'), cast(center_y, 'text'), cast(q1_x, 'text'), cast(center_y, 'text'), Attr.Shapes(null, null, null, pStrokeColor, 1, null, null), null), Element.Line(cast(q3_x, 'text'), cast(center_y, 'text'), cast(upper_whisker_x, 'text'), cast(center_y, 'text'), Attr.Shapes(null, null, null, pStrokeColor, 1, null, null), null), Element.Line(cast(lower_whisker_x, 'text'), cast(cap_top, 'text'), cast(lower_whisker_x, 'text'), cast(cap_bot, 'text'), Attr.Shapes(null, null, null, pStrokeColor, 1, null, null), null), Element.Line(cast(upper_whisker_x, 'text'), cast(cap_top, 'text'), cast(upper_whisker_x, 'text'), cast(cap_bot, 'text'), Attr.Shapes(null, null, null, pStrokeColor, 1, null, null), null), Element.Rect(cast(q1_x, 'text'), cast(cap_top, 'text'), cast(q3_x - q1_x, 'text'), cast(box_h, 'text'), '2', '2', Attr.Shapes(pFillColor, 0.5, null, pStrokeColor, 1, null, null), null), Element.Line(cast(median_x, 'text'), cast(cap_top, 'text'), cast(median_x, 'text'), cast(cap_bot, 'text'), Attr.Shapes(null, null, null, pStrokeColor, 2, null, null), null), coalesce( unique(pAxisRef) | select(v: pMeasureRef) | filter(v < lower_whisker_x or v > upper_whisker_x) | select( svg: Element.Circle( cast(Scale.Normalize( v, axis_min, axis_max, lo, hi, ), 'text'), cast(center_y, 'text'), '2.5', Attr.Shapes(coalesce(pFillColor, Color.Theme('Power BI', 1)), 0.35, null, pStrokeColor, 1, 0.85, null), null ) ) | string_agg(svg, sep: ''), '' ), ) ;; } // The line chart body: one point per axis value, scaled to the plot area. function Line { param pX { type: 'number' } param pY { type: 'number' } param pWidth { type: 'number' } param pHeight { type: 'number' } param pPaddingX { type: 'number' } param pPaddingY { type: 'number' } param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } param pLineColor { type: 'text' } return_type: 'text' definition: @aql const color = coalesce(pLineColor, '#01B8AA'); const n = Axis.Count(pAxisModel, pAxisRef, pMeasureRef); const v_min = Axis.Min(pAxisModel, pAxisRef, pMeasureRef); const v_max = Axis.Max(pAxisModel, pAxisRef, pMeasureRef); const d_min = Axis.Min(pAxisModel, pAxisRef, min(epoch(pAxisRef)) / 86400); const d_max = Axis.Max(pAxisModel, pAxisRef, min(epoch(pAxisRef)) / 86400); const y_lo = case(when: v_min > 0, then: 0, else: v_min); const y_hi = case(when: v_max > y_lo, then: v_max, else: y_lo + 1); const x_span = case(when: d_max > d_min, then: d_max - d_min, else: 1); case( when: n == 1, then: Element.Circle( cast(Offset(pX, pWidth, pPaddingX) + Plot(pWidth, pPaddingX) / 2, 'text'), cast(Scale.Normalize(v_max, y_lo, y_hi, Offset(pY, pHeight, pPaddingY) + Plot(pHeight, pPaddingY), Offset(pY, pHeight, pPaddingY)), 'text'), '2', Attr.Shapes(color, null, null, null, null, null, null), null ), when: n > 1, then: concat( "", Element.Polyline( coalesce( unique(pAxisRef) | select(v: pMeasureRef, d: min(epoch(pAxisRef)) / 86400) | filter(v is not null) | select(pt: concat(lpad(cast(d, 'text'), 10, '0'), ',', cast(0 - v, 'text'))) | string_agg(pt, sep: ' ', order: 'asc'), '' ), concat( Attr.Shapes('none', null, null, color, 1.5, null, null), "stroke-linejoin='round' vector-effect='non-scaling-stroke' " ), null ), '' ), else: '' ) ;; } } // The charts you call from a metric. module Viz: AqlFunctionModule { function ProgressBar { param pValueRefExpr { type: 'number' mode: 'expr' } param pTrackRefExpr { type: 'number' mode: 'expr' } param pColor { type: 'text' } param pOrientation { type: 'text' } param pWidth { type: 'number' } param pHeight { type: 'number' } return_type: 'text' definition: @aql SVG( '100%', '100%', concat('0 0 ', cast(coalesce(pWidth, 120), 'text'), ' ', cast(coalesce(pHeight, 48), 'text')), Compound.ProgressBar( 0, 0, coalesce(pWidth, 120), coalesce(pHeight, 48), 0, 0, pValueRefExpr, pTrackRefExpr, coalesce(pColor, Color.Theme('Power BI', 1)), '#E1DFDD', pOrientation ), null ) ;; } function Boxplot { param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } param pFillColor { type: 'text' } param pStrokeColor { type: 'text' } param pWidth { type: 'number' } param pHeight { type: 'number' } return_type: 'text' definition: @aql SVG( '100%', '100%', concat('0 0 ', cast(coalesce(pWidth, 120), 'text'), ' ', cast(coalesce(pHeight, 48), 'text')), Compound.Boxplot( 0, 0, coalesce(pWidth, 120), coalesce(pHeight, 48), 0, 0, pAxisModel, pAxisRef, pMeasureRef, coalesce(pFillColor, Color.Theme('Power BI', 1)), coalesce(pStrokeColor, '#333333') ), null ) ;; } function Line { param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } param pColor { type: 'text' } param pWidth { type: 'number' } param pHeight { type: 'number' } return_type: 'text' definition: @aql SVG( '100%', '100%', concat('0 0 ', cast(coalesce(pWidth, 120), 'text'), ' ', cast(coalesce(pHeight, 48), 'text')), Compound.Line( 0, 0, coalesce(pWidth, 120), coalesce(pHeight, 48), 0, 0.1, pAxisModel, pAxisRef, pMeasureRef, pColor ), null ) ;; } } // Per-row statistics for the box plot and line chart. // Each one aggregates the measure across the values of pAxisRef, for the current row. module Axis: AqlFunctionModule { function Min { param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } return_type: 'number' definition: @aql unique(pAxisRef) | select(x: pMeasureRef) | min(x) | of_all(pAxisRef) ;; } function Max { param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } return_type: 'number' definition: @aql unique(pAxisRef) | select(x: pMeasureRef) | max(x) | of_all(pAxisRef) ;; } function Percentile { param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } param pPercentile { type: 'number' } return_type: 'number' definition: @aql unique(pAxisRef) | select(x: pMeasureRef) | percentile_cont(x, pPercentile) | of_all(pAxisRef) ;; } // Number of axis values with a non-null measure. function Count { param pAxisModel { type: 'model' } param pAxisRef { type: 'dimension' mode: 'expr' } param pMeasureRef { type: 'number' mode: 'expr' } return_type: 'number' definition: @aql unique(pAxisRef) | select(x: pMeasureRef) | count(x) | of_all(pAxisRef) ;; } } // Pins the chart to a fixed size inside a table cell. module Html: AqlFunctionModule { function Box { param pWidth { type: 'number' } param pHeight { type: 'number' } param pContents { type: 'text' } return_type: 'text' definition: @aql concat( '', pContents, '' ) ;; } } } ``` ## Step 2: Import it into a dataset ```aml Dataset ecommerce { models: [orders, order_items, products] function_module SVG: SVG } ``` ## Step 3: Pick a chart - [Box Plot](./box-plot.md) - [Progress Bar](./progress-bar.md) - [Line Chart](./line-chart.md) ## Credits Built by Tan Huynh. Ported from DaxLib's `daxlib.svg`, released under the MIT license.