Deneb/Vega custom visual shows generic "sad face" icon instead of empty-state message — is this expected behavior for empty datasets?
Tags to use: powerbi, vega, vega-lite, deneb, data-visualization
Environment
Power BI Desktop (Mai 2026 build)
Deneb custom visual, version 1.9.1.0
Vega spec schema:
https://vega.github.io/schema/vega/v5.jsonDeneb's internal renderer reports:
The input spec uses Vega v5, but the current version of Vega is v6.2.0(informational warning only, not an error)
Summary
I have a Deneb visual (raw Vega, not Vega-Lite) rendering a Gantt-style milestone timeline. When a Power BI filter/slicer combination results in zero rows reaching one of my derived datasets, the visual shows Deneb's generic "☹" (sad face) placeholder instead of rendering an empty/blank chart area.
At first I assumed this was a JavaScript runtime crash, but after setting the Deneb Logs panel to Log level: Error, there are no error entries at all — the log is completely empty. Only [Warn] level messages appear (schema-validation warnings about the v5/v6.2.0 mismatch on my time scale, which I believe are false positives caused by using a signal-based domain instead of a data/field-based one — happy to be corrected).
Question
Is the "☹" icon Deneb's standard placeholder for "zero marks rendered" (i.e., not actually an error), or does it always indicate a caught exception even when nothing shows in the Error-level log?
Is there a supported way to distinguish, from the Vega spec itself, between "my data is legitimately empty" vs. "something upstream threw and got swallowed" — ideally by rendering a custom "No data for this selection" text mark instead of relying on Deneb's default icon?
Is there a more idiomatic Vega pattern than what I'm doing below for guarding a
timescale'sdomainagainst an empty upstream dataset?
What I already tried
- Added a guard signal so the
xTimescale never receives anundefineddomain when the filtered dataset is empty:
{
"name": "safeDomain",
"update": "length(data('milestones')) > 0 ? extent(pluck(data('milestones'), 'milestoneDate')) : [datetime(year(now())-1,0,1), datetime(year(now())+1,11,31)]"
}
"scales": [
{
"name": "xTime",
"type": "time",
"domain": { "signal": "safeDomain" },
"range": [{ "signal": "timelineStart" }, { "signal": "timelineEnd" }],
"nice": true,
"zero": false
}
]
This fixed an actual crash (confirmed via the standalone Vega Editor: domain('xTime')[0]/[1] were undefined before the fix, causing an exception under "nice": true).
Found and removed a duplicate
datasource definition ({"name": "milestones"}with nosource/transform, shadowing the real one further down the array) that was also contributing to blank renders.After both fixes, the chart does render correctly when data is present. The "☹" now only appears when a filter selection legitimately yields 0 rows — but I can't yet tell programmatically/visually whether that's "correctly empty" or "another swallowed error", short of manually opening the Logs panel every time.
Minimal reproducible example
Here's a trimmed-down spec that reproduces the "☹ with empty Error log" behavior when the injected dataset has 0 rows (paste into the Vega Editor with an empty values: [] array for dataset):
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": 400,
"height": 200,
"signals": [
{
"name": "safeDomain",
"update": "length(data('milestones')) > 0 ? extent(pluck(data('milestones'), 'milestoneDate')) : [datetime(2025,0,1), datetime(2027,11,31)]"
}
],
"data": [
{ "name": "dataset", "values": [] },
{
"name": "milestones",
"source": "dataset",
"transform": [
{ "type": "formula", "as": "milestoneDate", "expr": "toDate(datum['Datum Ende'])" },
{ "type": "filter", "expr": "isValid(datum.milestoneDate)" }
]
}
],
"scales": [
{
"name": "xTime",
"type": "time",
"domain": { "signal": "safeDomain" },
"range": [10, 390],
"nice": true
}
],
"axes": [{ "scale": "xTime", "orient": "bottom" }],
"marks": [
{
"type": "symbol",
"from": { "data": "milestones" },
"encode": {
"enter": {
"x": { "scale": "xTime", "field": "milestoneDate" },
"y": { "value": 100 },
"shape": { "value": "diamond" }
}
}
}
]
}
With values: [], this renders fine in the standalone Vega Editor (empty chart, valid axis 2025–2027, no error) — but the equivalent situation inside Deneb/Power BI shows the "☹" icon instead of the empty axis. I'd expect Deneb to just render what Vega itself renders (an empty-but-valid chart), so I'm not sure whether this is Deneb-specific behavior, a Power BI data-binding quirk, or something in my full production spec I haven't isolated yet.
What I'd like to know
Is this documented Deneb behavior (a deliberate "nothing to show" placeholder)?
Best-practice pattern for explicitly rendering a "No data for this selection" text mark instead, so end users can tell "empty by design" apart from "something broke" without opening dev tools?
Any pointers to Deneb's source/docs on this specific icon would also be appreciated.
Appendix: full production specs
In case the minimal example above doesn't reproduce it for you, here are the two complete, currently-working specs (post-fix) exactly as used in the report. Visual 1 is a thin header/axis bar; Visual 2 is the main 640px content chart that draws the milestone diamonds. Both pull from the same underlying Power BI table.
Visual 1 — header/axis spec (full)
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": {
"signal": "pbiContainerWidth"
},
"height": 18,
"autosize": {
"type": "fit",
"contains": "padding",
"resize": true
},
"padding": {
"left": 10,
"right": 10,
"top": 0,
"bottom": 0
},
"signals": [
{
"name": "labelWidth",
"value": 420
},
{
"name": "timelineStart",
"update": "labelWidth + 20"
},
{
"name": "timelineEnd",
"update": "width - 20"
},
{
"name": "safeDomain",
"update": "length(data('milestones')) > 0 ? extent(pluck(data('milestones'), 'milestoneDate')) : [datetime(year(now())-1,0,1), datetime(year(now())+1,11,31)]"
},
{
"name": "visibleYears",
"update": "(domain('xTime')[1] - domain('xTime')[0]) / 31557600000"
}
],
"data": [
{
"name": "dataset"
},
{
"name": "milestones",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "isValid(datum['Datum Ende'])"
},
{
"type": "filter",
"expr": "!isValid(datum['Freier Filterkatalog']) || trim(toString(datum['Freier Filterkatalog'])) !== ''"
},
{
"type": "formula",
"as": "milestoneDate",
"expr": "toDate(datum['Datum Ende'])"
},
{
"type": "filter",
"expr": "isValid(datum.milestoneDate)"
}
]
}
],
"scales": [
{
"name": "xTime",
"type": "time",
"domain": {
"signal": "safeDomain"
},
"range": [
{
"signal": "timelineStart"
},
{
"signal": "timelineEnd"
}
],
"nice": true,
"zero": false
}
],
"axes": [
{
"scale": "xTime",
"orient": "bottom",
"offset": 0,
"tickCount": {
"signal": "visibleYears <= 1.5 ? {interval: 'month', step: 3} : visibleYears <= 8 ? {interval: 'year', step: 1} : visibleYears <= 20 ? {interval: 'year', step: 2} : visibleYears <= 40 ? {interval: 'year', step: 5} : {interval: 'year', step: 10}"
},
"labelExpr": "visibleYears <= 1.5 ? ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'][month(datum.value)] + ' ' + year(datum.value) : timeFormat(datum.value, '%Y')",
"labelFontSize": 10,
"labelColor": "#555555",
"labelPadding": 1,
"labelOverlap": "greedy",
"labelFlush": false,
"tickSize": 3,
"tickColor": "#A0A0A0",
"domain": true,
"domainColor": "#A0A0A0",
"domainWidth": 1,
"grid": false,
"title": null
}
],
"marks": [
{
"type": "rule",
"encode": {
"update": {
"x": {
"signal": "timelineStart"
},
"x2": {
"signal": "timelineStart"
},
"y": {
"value": 0
},
"y2": {
"signal": "height"
},
"stroke": {
"value": "#D0D0D0"
},
"strokeWidth": {
"value": 1
}
}
}
}
]
}
Visual 2 — content spec (full)
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": {
"signal": "pbiContainerWidth"
},
"height": 640,
"autosize": {
"type": "fit-x",
"contains": "padding",
"resize": true
},
"padding": {
"left": 10,
"right": 10,
"top": 2,
"bottom": 10
},
"signals": [
{
"name": "rowHeight",
"value": 20
},
{
"name": "topPadding",
"value": 8
},
{
"name": "labelWidth",
"value": 420
},
{
"name": "projectDescriptionLength",
"value": 25
},
{
"name": "timelineStart",
"update": "labelWidth + 20"
},
{
"name": "timelineEnd",
"update": "width - 20"
},
{
"name": "safeDomain",
"update": "length(data('milestones')) > 0 ? extent(pluck(data('milestones'), 'milestoneDate')) : [datetime(year(now())-1,0,1), datetime(year(now())+1,11,31)]"
},
{
"name": "dataStatusDate",
"update": "data('dataStatus').length > 0 && isValid(data('dataStatus')[0].dataStatusDate) ? data('dataStatus')[0].dataStatusDate : datetime(year(now()), month(now()), date(now()))"
},
{
"name": "contentHeight",
"update": "data('contentExtent').length > 0 && isValid(data('contentExtent')[0].maxBlockEnd) ? topPadding + data('contentExtent')[0].maxBlockEnd * rowHeight + rowHeight : topPadding"
}
],
"data": [
{
"name": "dataset"
},
{
"name": "dataStatus",
"source": "dataset",
"transform": [
{
"type": "aggregate",
"fields": [
"SourceDataTimestamp"
],
"ops": [
"max"
],
"as": [
"dataStatusRaw"
]
},
{
"type": "formula",
"as": "dataStatusDate",
"expr": "toDate(datum.dataStatusRaw)"
}
]
},
{
"name": "projectLabels",
"source": "dataset",
"transform": [
{
"type": "aggregate",
"groupby": [
"Projektdefinition DB"
],
"fields": [
"Projektdefinition DB Bezeichnung"
],
"ops": [
"max"
],
"as": [
"Projektbezeichnung"
]
},
{
"type": "formula",
"as": "projectSort",
"expr": "toString(datum['Projektdefinition DB'])"
}
]
},
{
"name": "modelsBase",
"source": "dataset",
"transform": [
{
"type": "aggregate",
"groupby": [
"Projektdefinition DB",
"Vorgangsmodell"
]
},
{
"type": "formula",
"as": "projectSort",
"expr": "toString(datum['Projektdefinition DB'])"
},
{
"type": "formula",
"as": "modelSort",
"expr": "isValid(datum['Vorgangsmodell']) && trim(toString(datum['Vorgangsmodell'])) !== '' ? trim(toString(datum['Vorgangsmodell'])) : ''"
},
{
"type": "formula",
"as": "hasNoModel",
"expr": "datum.modelSort === ''"
},
{
"type": "formula",
"as": "modelKey",
"expr": "datum.projectSort + '||' + datum.modelSort"
}
]
},
{
"name": "projectModelCounts",
"source": "modelsBase",
"transform": [
{
"type": "filter",
"expr": "datum.modelSort !== ''"
},
{
"type": "aggregate",
"groupby": [
"projectSort"
],
"ops": [
"count"
],
"as": [
"modelCount"
]
}
]
},
{
"name": "catalogsBase",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "isValid(datum['Freier Filterkatalog']) && trim(toString(datum['Freier Filterkatalog'])) !== ''"
},
{
"type": "filter",
"expr": "!(trim(toString(datum['Freier Filterkatalog'])) === 'TK00' && (!isValid(datum['Vorgangsmodell']) || trim(toString(datum['Vorgangsmodell'])) === ''))"
},
{
"type": "aggregate",
"groupby": [
"Projektdefinition DB",
"Vorgangsmodell",
"Freier Filterkatalog",
"Freier Filterkatalog Bezeichnung"
]
},
{
"type": "formula",
"as": "projectSort",
"expr": "toString(datum['Projektdefinition DB'])"
},
{
"type": "formula",
"as": "modelSort",
"expr": "isValid(datum['Vorgangsmodell']) && trim(toString(datum['Vorgangsmodell'])) !== '' ? trim(toString(datum['Vorgangsmodell'])) : ''"
},
{
"type": "formula",
"as": "catalogSort",
"expr": "trim(toString(datum['Freier Filterkatalog']))"
},
{
"type": "formula",
"as": "modelKey",
"expr": "datum.projectSort + '||' + datum.modelSort"
},
{
"type": "formula",
"as": "catalogKey",
"expr": "datum.projectSort + '||' + datum.modelSort + '||' + datum.catalogSort"
},
{
"type": "formula",
"as": "label",
"expr": "datum.catalogSort + (isValid(datum['Freier Filterkatalog Bezeichnung']) && trim(toString(datum['Freier Filterkatalog Bezeichnung'])) !== '' ? ' - ' + trim(toString(datum['Freier Filterkatalog Bezeichnung'])) : '')"
},
{
"type": "window",
"groupby": [
"modelKey"
],
"sort": {
"field": [
"catalogSort"
],
"order": [
"ascending"
]
},
"ops": [
"row_number"
],
"as": [
"catalogIndex"
]
}
]
},
{
"name": "catalogCounts",
"source": "catalogsBase",
"transform": [
{
"type": "aggregate",
"groupby": [
"modelKey"
],
"ops": [
"count"
],
"as": [
"catalogCount"
]
}
]
},
{
"name": "modelBlocks",
"source": "modelsBase",
"transform": [
{
"type": "lookup",
"from": "catalogCounts",
"key": "modelKey",
"fields": [
"modelKey"
],
"values": [
"catalogCount"
],
"as": [
"catalogCount"
]
},
{
"type": "lookup",
"from": "projectModelCounts",
"key": "projectSort",
"fields": [
"projectSort"
],
"values": [
"modelCount"
],
"as": [
"modelCount"
]
},
{
"type": "lookup",
"from": "projectLabels",
"key": "projectSort",
"fields": [
"projectSort"
],
"values": [
"Projektbezeichnung"
],
"as": [
"Projektbezeichnung"
]
},
{
"type": "formula",
"as": "catalogCountClean",
"expr": "isValid(datum.catalogCount) ? datum.catalogCount : 0"
},
{
"type": "formula",
"as": "modelCountClean",
"expr": "isValid(datum.modelCount) ? datum.modelCount : 0"
},
{
"type": "formula",
"as": "ProjektbezeichnungKurz",
"expr": "isValid(datum.Projektbezeichnung) ? (length(toString(datum.Projektbezeichnung)) > projectDescriptionLength ? substring(toString(datum.Projektbezeichnung), 0, projectDescriptionLength) + '…' : toString(datum.Projektbezeichnung)) : ''"
},
{
"type": "formula",
"as": "headerLabel",
"expr": "datum.projectSort + ' - ' + datum.ProjektbezeichnungKurz + (datum.hasNoModel ? '' : ' | ' + datum.modelSort)"
},
{
"type": "formula",
"as": "blockSize",
"expr": "datum.catalogCountClean + 1"
},
{
"type": "stack",
"groupby": [],
"sort": {
"field": [
"projectSort",
"modelSort"
],
"order": [
"ascending",
"ascending"
]
},
"field": "blockSize",
"offset": "zero",
"as": [
"blockStart",
"blockEnd"
]
},
{
"type": "formula",
"as": "headerRow",
"expr": "datum.blockStart + 1"
}
]
},
{
"name": "contentExtent",
"source": "modelBlocks",
"transform": [
{
"type": "aggregate",
"fields": [
"blockEnd"
],
"ops": [
"max"
],
"as": [
"maxBlockEnd"
]
}
]
},
{
"name": "catalogs",
"source": "catalogsBase",
"transform": [
{
"type": "lookup",
"from": "modelBlocks",
"key": "modelKey",
"fields": [
"modelKey"
],
"values": [
"headerRow"
],
"as": [
"headerRow"
]
},
{
"type": "formula",
"as": "rowIndex",
"expr": "datum.headerRow + datum.catalogIndex"
}
]
},
{
"name": "milestones",
"source": "dataset",
"transform": [
{
"type": "filter",
"expr": "isValid(datum['Datum Ende']) && isValid(datum['Freier Filterkatalog']) && trim(toString(datum['Freier Filterkatalog'])) !== ''"
},
{
"type": "filter",
"expr": "!(trim(toString(datum['Freier Filterkatalog'])) === 'TK00' && (!isValid(datum['Vorgangsmodell']) || trim(toString(datum['Vorgangsmodell'])) === ''))"
},
{
"type": "formula",
"as": "projectSort",
"expr": "toString(datum['Projektdefinition DB'])"
},
{
"type": "formula",
"as": "modelSort",
"expr": "isValid(datum['Vorgangsmodell']) && trim(toString(datum['Vorgangsmodell'])) !== '' ? trim(toString(datum['Vorgangsmodell'])) : ''"
},
{
"type": "formula",
"as": "catalogSort",
"expr": "trim(toString(datum['Freier Filterkatalog']))"
},
{
"type": "formula",
"as": "catalogKey",
"expr": "datum.projectSort + '||' + datum.modelSort + '||' + datum.catalogSort"
},
{
"type": "formula",
"as": "milestoneDate",
"expr": "toDate(datum['Datum Ende'])"
},
{
"type": "filter",
"expr": "isValid(datum.milestoneDate)"
},
{
"type": "lookup",
"from": "catalogs",
"key": "catalogKey",
"fields": [
"catalogKey"
],
"values": [
"rowIndex"
],
"as": [
"rowIndex"
]
},
{
"type": "filter",
"expr": "isValid(datum.rowIndex)"
},
{
"type": "formula",
"as": "statusNormalized",
"expr": "lower(trim(isValid(datum['Status Meilenstein']) ? toString(datum['Status Meilenstein']) : ''))"
},
{
"type": "formula",
"as": "milestoneColor",
"expr": "datum.statusNormalized === 'erledigt' ? '#B3B3B3' : datum.statusNormalized === 'überfällig' ? '#FF0000' : datum.statusNormalized === 'fällig kurzfristig' ? '#FFFF00' : datum.statusNormalized === 'fällig mittelfristig' ? '#000000' : datum.statusNormalized === 'fällig langfristig' ? '#000000' : '#000000'"
},
{
"type": "formula",
"as": "milestoneLabel",
"expr": "(isValid(datum['Katalogvorgang']) ? toString(datum['Katalogvorgang']) : '') + (isValid(datum['Vorgangsbezeichnung DB En']) && trim(toString(datum['Vorgangsbezeichnung DB En'])) !== '' ? ' - ' + toString(datum['Vorgangsbezeichnung DB En']) : '')"
}
]
}
],
"scales": [
{
"name": "xTime",
"type": "time",
"domain": {
"signal": "safeDomain"
},
"range": [
{
"signal": "timelineStart"
},
{
"signal": "timelineEnd"
}
],
"nice": true,
"zero": false
}
],
"axes": [],
"marks": [
{
"type": "rule",
"encode": {
"update": {
"x": {
"signal": "timelineStart"
},
"x2": {
"signal": "timelineStart"
},
"y": {
"value": 0
},
"y2": {
"signal": "contentHeight"
},
"stroke": {
"value": "#D0D0D0"
},
"strokeWidth": {
"value": 1
}
}
}
},
{
"type": "rule",
"from": {
"data": "modelBlocks"
},
"encode": {
"enter": {
"x": {
"value": 0
},
"x2": {
"signal": "width"
},
"y": {
"signal": "topPadding + (datum.headerRow - 0.5) * rowHeight"
},
"stroke": {
"value": "#E0E0E0"
},
"strokeWidth": {
"value": 1
}
}
}
},
{
"type": "rule",
"encode": {
"update": {
"x": {
"scale": "xTime",
"signal": "dataStatusDate"
},
"x2": {
"scale": "xTime",
"signal": "dataStatusDate"
},
"y": {
"signal": "topPadding"
},
"y2": {
"signal": "contentHeight"
},
"stroke": {
"value": "#FF0000"
},
"strokeWidth": {
"value": 1.5
},
"strokeDash": {
"value": [
5,
4
]
},
"opacity": {
"signal": "dataStatusDate >= domain('xTime')[0] && dataStatusDate <= domain('xTime')[1] ? 0.9 : 0"
}
}
}
},
{
"type": "text",
"encode": {
"update": {
"x": {
"scale": "xTime",
"signal": "dataStatusDate",
"offset": 5
},
"y": {
"signal": "topPadding + 1"
},
"text": {
"signal": "'Datenstand ' + timeFormat(dataStatusDate, '%d.%m.%Y')"
},
"fontSize": {
"value": 12
},
"fontWeight": {
"value": "bold"
},
"fill": {
"value": "#FF0000"
},
"align": {
"value": "left"
},
"baseline": {
"value": "top"
},
"opacity": {
"signal": "dataStatusDate >= domain('xTime')[0] && dataStatusDate <= domain('xTime')[1] ? 1 : 0"
}
}
}
},
{
"name": "warningSymbol",
"type": "text",
"from": {
"data": "modelBlocks"
},
"encode": {
"update": {
"x": {
"value": 8
},
"y": {
"signal": "topPadding + datum.headerRow * rowHeight"
},
"text": {
"value": "⚠"
},
"align": {
"value": "left"
},
"baseline": {
"value": "middle"
},
"fontSize": {
"value": 14
},
"fill": {
"value": "#8D6E63"
},
"opacity": {
"signal": "datum.hasNoModel ? 1 : 0"
}
}
}
},
{
"type": "text",
"from": {
"data": "modelBlocks"
},
"encode": {
"update": {
"x": {
"signal": "datum.hasNoModel ? 28 : 10"
},
"y": {
"signal": "topPadding + datum.headerRow * rowHeight"
},
"text": {
"field": "headerLabel"
},
"align": {
"value": "left"
},
"baseline": {
"value": "middle"
},
"fontSize": {
"value": 12
},
"fontWeight": {
"value": "bold"
},
"fill": {
"signal": "datum.hasNoModel ? '#8D6E63' : datum.modelCountClean > 1 ? '#1565C0' : '#222222'"
},
"limit": {
"signal": "labelWidth - (datum.hasNoModel ? 38 : 20)"
}
}
}
},
{
"type": "text",
"from": {
"data": "catalogs"
},
"encode": {
"enter": {
"x": {
"value": 38
},
"y": {
"signal": "topPadding + datum.rowIndex * rowHeight"
},
"text": {
"field": "label"
},
"align": {
"value": "left"
},
"baseline": {
"value": "middle"
},
"fontSize": {
"value": 11
},
"fill": {
"value": "#666666"
},
"limit": {
"signal": "labelWidth - 50"
}
}
}
},
{
"type": "symbol",
"from": {
"data": "milestones"
},
"encode": {
"enter": {
"x": {
"scale": "xTime",
"field": "milestoneDate"
},
"y": {
"signal": "topPadding + datum.rowIndex * rowHeight"
},
"shape": {
"value": "diamond"
},
"fill": {
"field": "milestoneColor"
},
"stroke": {
"value": "#222222"
},
"strokeWidth": {
"value": 1
},
"tooltip": {
"signal": "{'Projekt': datum['Projektdefinition DB'], 'Vorgangsmodell': isValid(datum['Vorgangsmodell']) ? datum['Vorgangsmodell'] : '', 'Terminkette': datum['Freier Filterkatalog'], 'Meilenstein': datum.milestoneLabel, 'Datum': timeFormat(datum.milestoneDate, '%d.%m.%Y'), 'Status': isValid(datum['Status Meilenstein']) ? datum['Status Meilenstein'] : ''}"
}
},
"update": {
"size": {
"value": 110
},
"opacity": {
"value": 1
}
},
"hover": {
"size": {
"value": 190
},
"opacity": {
"value": 0.75
}
}
}
}
]
}