Chez Ventriloc, nous sommes convaincus qu’un tableau de bord efficace doit être bien plus qu’une simple collection de graphiques : il doit permettre de répondre d’un seul coup d’œil aux questions les plus stratégiques de nos clients. Ainsi, lorsqu’un défi technique se présente, nous ne le considérons pas comme une contrainte, mais comme une opportunité d’innover. Nous sommes fiers de partager ce cas où nous avons dépassé les limites des visuels natifs de Power BI afin de proposer une solution élégante, maintenable et hautement fonctionnelle.
Le projet consistait à concevoir un visuel inexistant nativement dans Power BI : un graphique intégrant les ventes, le coût des ventes (avec un toggle pour l’afficher ou le masquer) et la comparaison avec la période précédente. La solution a combiné la flexibilité du custom Visual Deneb (Vega-Lite) avec des techniques basées sur SVG et HTML, afin d’améliorer l’expérience utilisateur.
Le défi du client
Notre client avait besoin de visualiser, sur une seule page, quatre KPI essentiels. Le plus complexe concernait les ventes pour simplifier l’explication, nous prendrons uniquement l’exemple des ventes. Pour ce KPI, Il fallait afficher simultanément les ventes actuelles et le coût des ventes, tout en appliquant un format conditionnel spécifique uniquement à la barre des ventes du dernier mois disponible. De plus, le visuel devait être interactif : l’utilisateur devait pouvoir choisir d’afficher ou non le coût en fonction de son analyse. Ce niveau de granularité (appliquer un format conditionnel uniquement à une série dans un graphique multi-séries) n’était pas réalisable avec les visuels standards de Power BI.
Les limites des visuels standards en matière de formatage conditionnel
Les visuels natifs de Power BI couvrent la majorité des besoins analytiques et offrent déjà des capacités de formatage conditionnel dans les tableaux, matrices et graphiques simples. Toutefois, comme le précise la documentation officielle de Microsoft, dans les graphiques en barres ou en colonnes, les règles de couleur s’appliquent uniformément à l’ensemble d’une série, et non de manière différenciée à l’intérieur d’un même visuel.
Concrètement, lorsqu’un graphique contient plusieurs mesures, il est impossible de conditionner une seule d’entre elles selon une logique spécifique.
Plutôt que de percevoir cette limitation comme un frein, nous y voyons un choix d’architecture volontairement orienté vers la clarté et la simplicité. Pour des cas d’usage plus avancés, Microsoft met à disposition des visuels personnalisés certifiés dans AppSource, tels que Deneb, qui offrent un niveau de flexibilité bien supérieur. Cela permet de combiner la robustesse des visuels natifs avec la créativité et la précision de solutions avancées.
Deneb au cœur de la solution
Le cœur de la solution repose sur un visuel Deneb, défini à l’aide d’un fichier Vega-Lite JSON comprenant :
- deux barres : une pour les ventes et une pour le coût (contrôlée par un toggle) ;
- une ligne avec point représentant la période précédente ;
- une logique conditionnelle pilotée par un Field Parameter permettant de basculer entre une analyse mensuelle, trimestrielle ou annuelle.
L’élément clé résidait dans le formatage conditionnel. Le JSON intégrait une règle identifiant la dernière période disponible, à laquelle une couleur verte était appliquée si les ventes dépassaient celles de la période précédente, ou rouge dans le cas inverse. Toutes les autres barres restaient volontairement en gris neutre, afin de diriger immédiatement l’attention vers l’information la plus critique.
JSON – DENEB – 2 barres, 1 format conditionnel, 1 ligne
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"usermeta": {
"information": {
"uuid": "800a282f-248f-4452-a16a-4b0512f25b72",
"generated": "2026-01-13T19:45:34.507Z",
"previewImageBase64PNG": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
"name": "trend_barchart_two_bars_one_conditional_format_plus_line",
"description": "Monthly trend chart with clustered bars for current Sales and Cost, plus a prior-period Sales line. The latest Sales bar is conditionally colored based on performance vs the prior period.",
"author": "Catalina Moreno"
},
"deneb": {
"build": "1.8.2.0",
"metaVersion": 1,
"provider": "vegaLite",
"providerVersion": "6.4.1"
},
"interactivity": {
"tooltip": true,
"contextMenu": true,
"selection": true,
"selectionMode": "simple",
"highlight": false,
"dataPointLimit": 50
},
"config": "{}",
"dataset": [
{
"key": "__0__",
"name": "Month",
"description": "",
"kind": "column",
"type": "dateTime"
},
{
"key": "__1__",
"name": "Sales",
"description": "",
"kind": "measure",
"type": "numeric"
},
{
"key": "__2__",
"name": "Cost",
"description": "",
"kind": "measure",
"type": "numeric"
},
{
"key": "__3__",
"name": "Sales (Previous Period)",
"description": "",
"kind": "measure",
"type": "numeric"
}
]
},
"data": {
"name": "dataset"
},
"transform": [
{
"calculate": "if (isDefined(datum['__0__']), timeFormat(datum['__0__'], '%b %Y'), if (isDefined(datum['Quarter']), timeFormat(datum['Quarter'], '%b %Y'), toString(datum['Year'])))",
"as": "FP"
},
{
"calculate": "if (isDefined(datum['__0__']), datum['__0__'], if (isDefined(datum['Quarter']), datum['Quarter'], datetime(datum['Year'], 0, 1)))",
"as": "FP_sort"
},
{
"window": [
{
"op": "row_number",
"as": "FP_index"
}
],
"sort": [
{
"field": "FP_index",
"order": "ascending"
}
]
}
],
"layer": [
{
"transform": [
{
"aggregate": [
{
"op": "sum",
"field": "__1__",
"as": "sum_Sales"
},
{
"op": "sum",
"field": "__3__",
"as": "sum_Prev_Sales"
}
],
"groupby": [
"FP",
"FP_sort",
"FP_index",
"__identity__",
"__selected__"
]
},
{
"window": [
{
"op": "row_number",
"as": "row_num_desc"
}
],
"sort": [
{
"field": "FP_sort",
"order": "descending"
}
]
},
{
"calculate": "datum.row_num_desc === 1 ? true : false",
"as": "is_latest"
},
{
"filter": "datum['sum_Sales'] != null"
}
],
"mark": {
"type": "bar",
"size": 8
},
"encoding": {
"x": {
"field": "FP",
"type": "ordinal",
"sort": {
"op": "min",
"field": "FP_index",
"order": "ascending"
},
"axis": {
"title": null,
"domain": true,
"domainColor": "#e6e6e6",
"domainWidth": 1,
"ticks": false,
"labelFont": "Segoe UI",
"labelFontSize": 11,
"labelPadding": 8,
"labelAngle": 0,
"labelOverlap": "greedy",
"labelSeparation": 5,
"labelBound": true
},
"scale": {
"paddingInner": 1
}
},
"y": {
"field": "sum_Sales",
"type": "quantitative",
"axis": {
"title": null,
"domain": false,
"ticks": false,
"grid": true,
"gridColor": "#e6e6e6",
"gridWidth": 1,
"labelFont": "Segoe UI",
"labelFontSize": 11,
"labelPadding": 8,
"labelExpr": "datum.value >= 1000000 ? format(datum.value/1000000, '$,.1f') + 'M' : datum.value >= 1000 ? format(datum.value/1000, '$,.0f') + 'k' : format(datum.value, '$,.0f')"
}
},
"color": {
"condition": [
{
"test": "datum.is_latest && datum['sum_Sales'] > datum['sum_Prev_Sales']",
"value": "#59B973"
},
{
"test": "datum.is_latest && datum['sum_Sales'] <= datum['sum_Prev_Sales']",
"value": "#CF202E"
}
],
"value": "#D9D9D9"
},
"xOffset": {
"value": -3
},
"tooltip": [
{
"field": "FP",
"title": "Period:"
},
{
"field": "sum_Sales",
"title": "Sales Current:",
"format": "$,.0f"
},
{
"field": "sum_Prev_Sales",
"title": "Sales Prior period:",
"format": "$,.0f"
}
],
"opacity": {
"condition": {
"test": {
"field": "__selected__",
"equal": "off"
},
"value": 0.3
},
"value": 1
}
}
},
{
"transform": [
{
"aggregate": [
{
"op": "sum",
"field": "__2__",
"as": "sum_Cost"
}
],
"groupby": [
"FP",
"FP_sort",
"FP_index",
"__identity__",
"__selected__"
]
},
{
"filter": "datum['sum_Cost'] != null"
}
],
"mark": {
"type": "bar",
"size": 8
},
"encoding": {
"x": {
"field": "FP",
"type": "ordinal",
"sort": {
"op": "min",
"field": "FP_index",
"order": "ascending"
},
"scale": {
"paddingInner": 0.4
}
},
"y": {
"field": "sum_Cost",
"type": "quantitative"
},
"color": {
"value": "#989898"
},
"xOffset": {
"value": 6
},
"tooltip": [
{
"field": "FP",
"title": "Period:"
},
{
"field": "sum_Cost",
"title": "Cost Current:",
"format": "$,.0f"
}
],
"opacity": {
"condition": {
"test": {
"field": "__selected__",
"equal": "off"
},
"value": 0.3
},
"value": 1
}
}
},
{
"transform": [
{
"aggregate": [
{
"op": "sum",
"field": "__3__",
"as": "sum_Prev_Sales"
}
],
"groupby": [
"FP",
"FP_sort",
"FP_index",
"__identity__",
"__selected__"
]
},
{
"filter": "datum['sum_Prev_Sales'] != null"
}
],
"mark": {
"type": "line",
"interpolate": "natural",
"point": {
"size": 20,
"filled": true,
"color": "#605E5C"
},
"strokeWidth": 1,
"color": "#605E5C"
},
"encoding": {
"x": {
"field": "FP",
"type": "ordinal",
"sort": {
"op": "min",
"field": "FP_index",
"order": "ascending"
},
"scale": {
"paddingInner": 0.6
},
"axis": {
"title": null,
"domain": true,
"domainColor": "#e6e6e6",
"domainWidth": 1,
"ticks": false,
"labelFont": "Segoe UI",
"labelFontSize": 11,
"labelPadding": 8,
"labelAngle": 0,
"labelOverlap": "greedy",
"labelSeparation": 5,
"labelBound": true
}
},
"y": {
"field": "sum_Prev_Sales",
"type": "quantitative"
},
"opacity": {
"condition": {
"test": {
"field": "__selected__",
"equal": "off"
},
"value": 0.3
},
"value": 1
}
}
}
],
"config": {
"mark": {
"invalid": null
},
"view": {
"stroke": null
},
"axis": {
"domainColor": "#666666",
"tickColor": "#666666",
"labelColor": "#666666",
"grid": false
}
}
}
Compléments avec SVG et HTML
Si Deneb constituait le socle du visuel, deux éléments complémentaires ont permis d’enrichir significativement l’expérience utilisateur.
Une légende dynamique en SVG
La légende a été générée dynamiquement via une mesure DAX retournant du SVG. Elle s’adaptait automatiquement à l’état du toggle du coût des ventes, garantissant une parfaite synchronisation avec le graphique affiché.
💡 Astuce
Pour aller plus vite, vous pouvez utiliser le plug-in Export SVG to Power BI pour Figma, conçu par Ventriloc. Il permet de convertir rapidement des visuels SVG en éléments directement exploitables dans Power BI, tout en réduisant les ajustements manuels.
Légende – Visuel SVG – One Pager
SVG Sales Legend onepager =
VAR _selection = LOWER(SELECTEDVALUE(others_selector_time_period[value]))
/*"data:image/svg+xml;utf8,"*/
var _with_cos =
"SalesCost of SalesAbove prior "&_selection&"Below prior "&_selection&"Prior "&_selection&""
VAR _without_cos =
"SalesAbove prior "&_selection&"Below prior "&_selection&"Prior "&_selection&""
RETURN
SWITCH(
SELECTEDVALUE(others_selector_cost_visible_onepager[is_cost_visible]),
"Yes",_with_cos,
"No",_without_cos,
_with_cos
)
Un indicateur de variation en HTML
Un indicateur de variation est conçu en HTML afin d’afficher l’écart entre les ventes actuelles et celles de la période précédente, avec des flèches, des couleurs et des pourcentages. Le recours au HTML permet de conserver un alignement visuel précis et cohérent, même lorsque la longueur des textes varie.
HTML – DAX – One Pager
HTML - Sales var vs previous period onepager =
-- Get the selected time period from the slicer (e.g., "Week", "Month", etc.)
VAR _period = SELECTEDVALUE(others_selector_time_period[value])
var _previous_period =
SWITCH(
SELECTEDVALUE(others_selector_time_period[value]),
"Week", MAX ( dim_date[start_of_week] ),
"Month", MAX(dim_date[start_of_month]), // Latest month in context
"Quarter", MAX(dim_date[start_of_quarter]),
"Year", MAX(dim_date[year])
)
-- Get the corresponding CURRENT value based on the selected period
VAR _current = [Switch Total Sales (current time period)]
-- Get the corresponding PREVIOUS value based on the selected period
VAR _previous = [Switch Total Sales (previous time period)]
-- Calculate the absolute difference
VAR _diff = _current - _previous
-- Calculate the relative % difference (safe division)
VAR _diff_pct = DIVIDE(_diff, _previous, BLANK())
-- Format both values for display
VAR _diff_formatted = FORMAT(_diff, "$#,0")
VAR _diff_pct_formatted = FORMAT(_diff_pct, "0.0%")
var _color = IF(_diff>0,"#59B973",IF(_diff0,"↑ ",IF(_diff0,"above ",IF(_diff<0,"below ","vs "))
RETURN
"<span style='";font-size:12px;font-family:Segoe UI;font-weight:600'>"&_arrow&""&_diff_formatted&" ("&_diff_pct_formatted&")</span> " &
"<span style='color:#666666;font-size:12px;font-family:Segoe UI;font-weight:600'>"&_word&""&[Note previous time period onepager]&"</span>"
Résultat final
Le visuel final se révèle à la fois puissant et flexible.
Lorsque l’option Show Cost of Sales est désactivée, l’utilisateur voit uniquement la barre des ventes accompagnée d’une légende simplifiée. Lorsqu’elle est activée, la barre du coût ainsi que la légende complète apparaissent automatiquement. Dans tous les cas, le formatage conditionnel met en évidence uniquement la dernière période de ventes.
Grâce à ce visuel unique, le client peut répondre à ses principales questions sans alourdir le tableau de bord, tout en accédant rapidement à des insights clés. Un bouton Access more details permet en outre de naviguer vers des rapports secondaires pour approfondir l’analyse.
Conclusion
Ce projet illustre parfaitement la valeur ajoutée des visuels personnalisés, qui se sont révélés être la solution la plus pertinente pour ce client, comme pour bien d’autres. Chez Ventriloc, nous ne nous limitons pas aux visuels standards de Power BI : nous exploitons pleinement notre expertise avancée afin de libérer le potentiel créatif de ces outils et d’élever la visualisation à un tout autre niveau.
Ce cas confirme notre conviction : un tableau de bord doit rester clair, précis et pensé avant tout pour l’utilisateur final. En combinant Deneb, SVG et HTML, nous avons transformé une contrainte apparente en véritable opportunité d’innovation, livrant un visuel unique dans Power BI qui aide aujourd’hui notre client à prendre des décisions plus rapides et plus éclairées.