diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 56bd01bbbc..0c932bf1a0 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -21608,6 +21608,10 @@ } ], "description": "Reverse x-scale by default (useful for right-to-left charts)." + }, + "zero": { + "description": "Default `scale.zero` for [`continuous`](https://vega.github.io/vega-lite/docs/scale.html#continuous) scales except for (1) x/y-scales of non-ranged bar or area charts and (2) size scales.\n\n__Default value:__ `true`", + "type": "boolean" } }, "type": "object" diff --git a/examples/compiled/bar_config_no_zero.png b/examples/compiled/bar_config_no_zero.png new file mode 100644 index 0000000000..01a1312af2 Binary files /dev/null and b/examples/compiled/bar_config_no_zero.png differ diff --git a/examples/compiled/bar_config_no_zero.svg b/examples/compiled/bar_config_no_zero.svg new file mode 100644 index 0000000000..9e458ba525 --- /dev/null +++ b/examples/compiled/bar_config_no_zero.svg @@ -0,0 +1 @@ +ABCDEFGHIa020406080100b \ No newline at end of file diff --git a/examples/compiled/bar_config_no_zero.vg.json b/examples/compiled/bar_config_no_zero.vg.json new file mode 100644 index 0000000000..a7403201ad --- /dev/null +++ b/examples/compiled/bar_config_no_zero.vg.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "description": "A simple bar chart with embedded data.", + "background": "white", + "padding": 5, + "height": 200, + "style": "cell", + "data": [ + { + "name": "source_0", + "values": [ + {"a": "A", "b": 28}, + {"a": "B", "b": 55}, + {"a": "C", "b": 43}, + {"a": "D", "b": 91}, + {"a": "E", "b": 81}, + {"a": "F", "b": 53}, + {"a": "G", "b": 19}, + {"a": "H", "b": 87}, + {"a": "I", "b": 52} + ] + }, + { + "name": "data_0", + "source": "source_0", + "transform": [ + { + "type": "stack", + "groupby": ["a"], + "field": "b", + "sort": {"field": [], "order": []}, + "as": ["b_start", "b_end"], + "offset": "zero" + }, + { + "type": "filter", + "expr": "isValid(datum[\"b\"]) && isFinite(+datum[\"b\"])" + } + ] + } + ], + "signals": [ + {"name": "x_step", "value": 20}, + { + "name": "width", + "update": "bandspace(domain('x').length, 0.1, 0.05) * x_step" + } + ], + "marks": [ + { + "name": "marks", + "type": "rect", + "style": ["bar"], + "from": {"data": "data_0"}, + "encode": { + "update": { + "fill": {"value": "#4c78a8"}, + "ariaRoleDescription": {"value": "bar"}, + "description": { + "signal": "\"a: \" + (isValid(datum[\"a\"]) ? datum[\"a\"] : \"\"+datum[\"a\"]) + \"; b: \" + (format(datum[\"b\"], \"\"))" + }, + "x": {"scale": "x", "field": "a"}, + "width": {"signal": "max(0.25, bandwidth('x'))"}, + "y": {"scale": "y", "field": "b_end"}, + "y2": {"scale": "y", "field": "b_start"} + } + } + } + ], + "scales": [ + { + "name": "x", + "type": "band", + "domain": {"data": "data_0", "field": "a", "sort": true}, + "range": {"step": {"signal": "x_step"}}, + "paddingInner": 0.1, + "paddingOuter": 0.05 + }, + { + "name": "y", + "type": "linear", + "domain": {"data": "data_0", "fields": ["b_start", "b_end"]}, + "range": [{"signal": "height"}, 0], + "nice": true, + "zero": true + } + ], + "axes": [ + { + "scale": "y", + "orient": "left", + "gridScale": "x", + "grid": true, + "tickCount": {"signal": "ceil(height/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "a", + "labelAngle": 0, + "labelBaseline": "top", + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "grid": false, + "title": "b", + "labelOverlap": true, + "tickCount": {"signal": "ceil(height/40)"}, + "zindex": 0 + } + ] +} diff --git a/examples/compiled/bar_gantt_config_no_zero.png b/examples/compiled/bar_gantt_config_no_zero.png new file mode 100644 index 0000000000..96b20dd940 Binary files /dev/null and b/examples/compiled/bar_gantt_config_no_zero.png differ diff --git a/examples/compiled/bar_gantt_config_no_zero.svg b/examples/compiled/bar_gantt_config_no_zero.svg new file mode 100644 index 0000000000..d31602a537 --- /dev/null +++ b/examples/compiled/bar_gantt_config_no_zero.svg @@ -0,0 +1,5 @@ +<<<<<<< Updated upstream +0246810start, endABCtask +======= +246810start, endABCtask +>>>>>>> Stashed changes diff --git a/examples/compiled/bar_gantt_config_no_zero.vg.json b/examples/compiled/bar_gantt_config_no_zero.vg.json new file mode 100644 index 0000000000..47a431f9b6 --- /dev/null +++ b/examples/compiled/bar_gantt_config_no_zero.vg.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "description": "A simple bar chart with ranged data (aka Gantt Chart).", + "background": "white", + "padding": 5, + "width": 200, + "style": "cell", + "data": [ + { + "name": "source_0", + "values": [ + {"task": "A", "start": 1, "end": 3}, + {"task": "B", "start": 3, "end": 8}, + {"task": "C", "start": 8, "end": 10} + ] + }, + { + "name": "data_0", + "source": "source_0", + "transform": [ + { + "type": "filter", + "expr": "isValid(datum[\"start\"]) && isFinite(+datum[\"start\"])" + } + ] + } + ], + "signals": [ + {"name": "y_step", "value": 20}, + { + "name": "height", + "update": "bandspace(domain('y').length, 0.1, 0.05) * y_step" + } + ], + "marks": [ + { + "name": "marks", + "type": "rect", + "style": ["bar"], + "from": {"data": "data_0"}, + "encode": { + "update": { + "fill": {"value": "#4c78a8"}, + "ariaRoleDescription": {"value": "bar"}, + "description": { + "signal": "\"start: \" + (format(datum[\"start\"], \"\")) + \"; task: \" + (isValid(datum[\"task\"]) ? datum[\"task\"] : \"\"+datum[\"task\"]) + \"; end: \" + (format(datum[\"end\"], \"\"))" + }, + "x": {"scale": "x", "field": "start"}, + "x2": {"scale": "x", "field": "end"}, + "y": {"scale": "y", "field": "task"}, + "height": {"signal": "max(0.25, bandwidth('y'))"} + } + } + } + ], + "scales": [ + { + "name": "x", + "type": "linear", + "domain": {"data": "data_0", "fields": ["start", "end"]}, + "range": [0, {"signal": "width"}], + "nice": true, + "zero": false + }, + { + "name": "y", + "type": "band", + "domain": {"data": "data_0", "field": "task", "sort": true}, + "range": {"step": {"signal": "y_step"}}, + "paddingInner": 0.1, + "paddingOuter": 0.05 + } + ], + "axes": [ + { + "scale": "x", + "orient": "bottom", + "gridScale": "y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "start, end", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "grid": false, + "title": "task", + "zindex": 0 + } + ] +} diff --git a/examples/compiled/point_2d_config_no_zero.png b/examples/compiled/point_2d_config_no_zero.png new file mode 100644 index 0000000000..868a9d1b5c Binary files /dev/null and b/examples/compiled/point_2d_config_no_zero.png differ diff --git a/examples/compiled/point_2d_config_no_zero.svg b/examples/compiled/point_2d_config_no_zero.svg new file mode 100644 index 0000000000..80ccd7f99b --- /dev/null +++ b/examples/compiled/point_2d_config_no_zero.svg @@ -0,0 +1,5 @@ +<<<<<<< Updated upstream +050100150200Horsepower01020304050Miles_per_Gallon +======= +50100150200Horsepower1020304050Miles_per_Gallon +>>>>>>> Stashed changes diff --git a/examples/compiled/point_2d_config_no_zero.vg.json b/examples/compiled/point_2d_config_no_zero.vg.json new file mode 100644 index 0000000000..b6de1de0d2 --- /dev/null +++ b/examples/compiled/point_2d_config_no_zero.vg.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "description": "A scatterplot showing horsepower and miles per gallons for various cars.", + "background": "white", + "padding": 5, + "width": 200, + "height": 200, + "style": "cell", + "data": [ + { + "name": "source_0", + "url": "data/cars.json", + "format": {"type": "json"}, + "transform": [ + { + "type": "filter", + "expr": "isValid(datum[\"Horsepower\"]) && isFinite(+datum[\"Horsepower\"]) && isValid(datum[\"Miles_per_Gallon\"]) && isFinite(+datum[\"Miles_per_Gallon\"])" + } + ] + } + ], + "marks": [ + { + "name": "marks", + "type": "symbol", + "style": ["point"], + "from": {"data": "source_0"}, + "encode": { + "update": { + "opacity": {"value": 0.7}, + "fill": {"value": "transparent"}, + "stroke": {"value": "#4c78a8"}, + "ariaRoleDescription": {"value": "point"}, + "description": { + "signal": "\"Horsepower: \" + (format(datum[\"Horsepower\"], \"\")) + \"; Miles_per_Gallon: \" + (format(datum[\"Miles_per_Gallon\"], \"\"))" + }, + "x": {"scale": "x", "field": "Horsepower"}, + "y": {"scale": "y", "field": "Miles_per_Gallon"} + } + } + } + ], + "scales": [ + { + "name": "x", + "type": "linear", + "domain": {"data": "source_0", "field": "Horsepower"}, + "range": [0, {"signal": "width"}], + "nice": true, + "zero": false + }, + { + "name": "y", + "type": "linear", + "domain": {"data": "source_0", "field": "Miles_per_Gallon"}, + "range": [{"signal": "height"}, 0], + "nice": true, + "zero": false + } + ], + "axes": [ + { + "scale": "x", + "orient": "bottom", + "gridScale": "y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "gridScale": "x", + "grid": true, + "tickCount": {"signal": "ceil(height/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "Horsepower", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "grid": false, + "title": "Miles_per_Gallon", + "labelOverlap": true, + "tickCount": {"signal": "ceil(height/40)"}, + "zindex": 0 + } + ] +} diff --git a/examples/specs/bar_config_no_zero.vl.json b/examples/specs/bar_config_no_zero.vl.json new file mode 100644 index 0000000000..4a155b00d7 --- /dev/null +++ b/examples/specs/bar_config_no_zero.vl.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "A simple bar chart with embedded data.", + "data": { + "values": [ + {"a": "A", "b": 28}, {"a": "B", "b": 55}, {"a": "C", "b": 43}, + {"a": "D", "b": 91}, {"a": "E", "b": 81}, {"a": "F", "b": 53}, + {"a": "G", "b": 19}, {"a": "H", "b": 87}, {"a": "I", "b": 52} + ] + }, + "mark": "bar", + "encoding": { + "x": {"field": "a", "type": "nominal", "axis": {"labelAngle": 0}}, + "y": {"field": "b", "type": "quantitative"} + }, + "config": { "scale": { "zero": false } } +} diff --git a/examples/specs/bar_gantt_config_no_zero.vl.json b/examples/specs/bar_gantt_config_no_zero.vl.json new file mode 100644 index 0000000000..66a0b32e13 --- /dev/null +++ b/examples/specs/bar_gantt_config_no_zero.vl.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "A simple bar chart with ranged data (aka Gantt Chart).", + "data": { + "values": [ + {"task": "A", "start": 1, "end": 3}, + {"task": "B", "start": 3, "end": 8}, + {"task": "C", "start": 8, "end": 10} + ] + }, + "mark": "bar", + "encoding": { + "y": {"field": "task", "type": "ordinal"}, + "x": {"field": "start", "type": "quantitative"}, + "x2": {"field": "end"} + }, + "config": { "scale": { "zero": false } } +} diff --git a/examples/specs/point_2d_config_no_zero.vl.json b/examples/specs/point_2d_config_no_zero.vl.json new file mode 100644 index 0000000000..977c8553c7 --- /dev/null +++ b/examples/specs/point_2d_config_no_zero.vl.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "A scatterplot showing horsepower and miles per gallons for various cars.", + "data": { "url": "data/cars.json" }, + "mark": "point", + "encoding": { + "x": { "field": "Horsepower", "type": "quantitative" }, + "y": { "field": "Miles_per_Gallon", "type": "quantitative" } + }, + "config": { "scale": { "zero": false } } +} diff --git a/site/docs/encoding/scale.md b/site/docs/encoding/scale.md index 847a2d6316..1b12563b27 100644 --- a/site/docs/encoding/scale.md +++ b/site/docs/encoding/scale.md @@ -452,7 +452,7 @@ To provide themes for all scales, the scale config (`config: {scale: {...}}`) ca #### Other -{% include table.html props="clamp,round,xReverse,useUnaggregatedDomain" source="ScaleConfig" %} +{% include table.html props="clamp,round,xReverse,useUnaggregatedDomain,zero" source="ScaleConfig" %} {:#range-config} diff --git a/src/compile/scale/properties.ts b/src/compile/scale/properties.ts index 501e9dfe3f..e4463fdd34 100644 --- a/src/compile/scale/properties.ts +++ b/src/compile/scale/properties.ts @@ -4,6 +4,7 @@ import {isBinned, isBinning, isBinParams} from '../../bin'; import { COLOR, FILL, + getSecondaryRangeChannel, isXorY, isXorYOffset, POLAR_POSITION_SCALE_CHANNELS, @@ -120,7 +121,8 @@ function parseUnitScaleProperty(model: UnitModel, property: Exclude; config: Config; + hasSecondaryRangeChannel: boolean; } export const scaleRules: { @@ -169,8 +172,8 @@ export const scaleRules: { const sort = isFieldDef(fieldOrDatumDef) ? fieldOrDatumDef.sort : undefined; return reverse(scaleType, sort, channel, config.scale); }, - zero: ({channel, fieldOrDatumDef, domain, markDef, scaleType}) => - zero(channel, fieldOrDatumDef, domain, markDef, scaleType) + zero: ({channel, fieldOrDatumDef, domain, markDef, scaleType, config, hasSecondaryRangeChannel}) => + zero(channel, fieldOrDatumDef, domain, markDef, scaleType, config.scale, hasSecondaryRangeChannel) }; // This method is here rather than in range.ts to avoid circular dependency. @@ -399,7 +402,9 @@ export function zero( fieldDef: TypedFieldDef | ScaleDatumDef, specifiedDomain: Domain, markDef: MarkDef, - scaleType: ScaleType + scaleType: ScaleType, + scaleConfig: ScaleConfig, + hasSecondaryRangeChannel: boolean ) { // If users explicitly provide a domain, we should not augment zero as that will be unexpected. const hasCustomDomain = !!specifiedDomain && specifiedDomain !== 'unaggregated'; @@ -418,7 +423,7 @@ export function zero( } } - // If there is no custom domain, return true only for the following cases: + // If there is no custom domain, return configZero value (=`true` as default) only for the following cases: // 1) using quantitative field with size // While this can be either ratio or interval fields, our assumption is that @@ -430,6 +435,7 @@ export function zero( // 2) non-binned, quantitative x-scale or y-scale // (For binning, we should not include zero by default because binning are calculated without zero.) + // (For area/bar charts with ratio scale chart, we should always include zero.) if ( !(isFieldDef(fieldDef) && fieldDef.bin) && util.contains([...POSITION_SCALE_CHANNELS, ...POLAR_POSITION_SCALE_CHANNELS], channel) @@ -441,7 +447,12 @@ export function zero( } } - return true; + if (contains(['bar', 'area'], type) && !hasSecondaryRangeChannel) { + return true; + } + + return scaleConfig?.zero; } + return false; } diff --git a/src/scale.ts b/src/scale.ts index a9bfd382f1..c90e7b1342 100644 --- a/src/scale.ts +++ b/src/scale.ts @@ -415,6 +415,14 @@ export interface ScaleConfig { * Reverse x-scale by default (useful for right-to-left charts). */ xReverse?: boolean | ES; + + /** + * Default `scale.zero` for [`continuous`](https://vega.github.io/vega-lite/docs/scale.html#continuous) scales except for (1) x/y-scales of non-ranged bar or area charts and (2) size scales. + * + * __Default value:__ `true` + * + */ + zero?: boolean; } export const defaultScaleConfig: ScaleConfig = { @@ -439,7 +447,9 @@ export const defaultScaleConfig: ScaleConfig = { minStrokeWidth: 1, maxStrokeWidth: 4, quantileCount: 4, - quantizeCount: 4 + quantizeCount: 4, + + zero: true }; export interface SchemeParams { diff --git a/test/compile/scale/properties.test.ts b/test/compile/scale/properties.test.ts index 82676906fc..1129cfc0fa 100644 --- a/test/compile/scale/properties.test.ts +++ b/test/compile/scale/properties.test.ts @@ -199,35 +199,63 @@ describe('compile/scale', () => { }); describe('zero', () => { - it('should return true when mapping a quantitative field to x with scale.domain = "unaggregated"', () => { + it('should return default (undefined) when mapping a quantitative field to x with scale.domain = "unaggregated"', () => { expect( - rules.zero('x', {field: 'a', type: 'quantitative'}, 'unaggregated', {type: 'point'}, 'linear') - ).toBeTruthy(); + rules.zero('x', {field: 'a', type: 'quantitative'}, 'unaggregated', {type: 'point'}, 'linear', undefined, false) + ).toBeUndefined(); }); it('should return true when mapping a quantitative field to size', () => { - expect(rules.zero('size', {field: 'a', type: 'quantitative'}, undefined, {type: 'point'}, 'linear')).toBeTruthy(); + expect( + rules.zero('size', {field: 'a', type: 'quantitative'}, undefined, {type: 'point'}, 'linear', undefined, false) + ).toBeTruthy(); }); it('should return false when mapping a ordinal field to size', () => { - expect(!rules.zero('size', {field: 'a', type: 'ordinal'}, undefined, {type: 'point'}, 'linear')).toBeTruthy(); + expect( + !rules.zero('size', {field: 'a', type: 'ordinal'}, undefined, {type: 'point'}, 'linear', undefined, false) + ).toBeTruthy(); }); - it('should return true when mapping a non-binned quantitative field to x/y of point', () => { + it('should return default (undefined) when mapping a non-binned quantitative field to x/y of point', () => { for (const channel of ['x', 'y'] as const) { expect( - rules.zero(channel, {field: 'a', type: 'quantitative'}, undefined, {type: 'point'}, 'linear') - ).toBeTruthy(); + rules.zero( + channel, + {field: 'a', type: 'quantitative'}, + undefined, + {type: 'point'}, + 'linear', + undefined, + false + ) + ).toBeUndefined(); } }); it('should return false when mapping a quantitative field to dimension axis of bar, line, and area', () => { for (const mark of [BAR, AREA, LINE]) { expect( - rules.zero('x', {field: 'a', type: 'quantitative'}, undefined, {type: mark, orient: 'vertical'}, 'linear') + rules.zero( + 'x', + {field: 'a', type: 'quantitative'}, + undefined, + {type: mark, orient: 'vertical'}, + 'linear', + undefined, + false + ) ).toBe(false); expect( - rules.zero('y', {field: 'a', type: 'quantitative'}, undefined, {type: mark, orient: 'horizontal'}, 'linear') + rules.zero( + 'y', + {field: 'a', type: 'quantitative'}, + undefined, + {type: mark, orient: 'horizontal'}, + 'linear', + undefined, + false + ) ).toBe(false); } }); @@ -235,7 +263,15 @@ describe('compile/scale', () => { it('should return false when mapping a binned quantitative field to x/y', () => { for (const channel of ['x', 'y'] as const) { expect( - !rules.zero(channel, {bin: true, field: 'a', type: 'quantitative'}, undefined, {type: 'point'}, 'linear') + !rules.zero( + channel, + {bin: true, field: 'a', type: 'quantitative'}, + undefined, + {type: 'point'}, + 'linear', + undefined, + false + ) ).toBeTruthy(); } }); @@ -252,10 +288,78 @@ describe('compile/scale', () => { }, [3, 5], {type: 'point'}, - 'linear' + 'linear', + undefined, + false ) ).toBeTruthy(); } }); + + it(`should return config.scale.zero instead of true if it is specified`, () => { + const configZero = false; + for (const channel of ['x', 'y'] as const) { + expect( + rules.zero( + channel, + {field: 'a', type: 'quantitative'}, + undefined, + {type: 'point'}, + 'linear', + {zero: configZero}, + false + ) + ).toBe(configZero); + } + + expect( + rules.zero( + 'size', + {field: 'a', type: 'ordinal'}, + undefined, + {type: 'point'}, + 'linear', + {zero: configZero}, + false + ) + ).toBe(configZero); + + // ranged bar/area should take default configZero + expect( + rules.zero('x', {field: 'a', type: 'quantitative'}, undefined, {type: BAR}, 'linear', {zero: configZero}, true) + ).toBe(configZero); + }); + + it(`should return true for x/y scales of the non-ranged area/bar charts regardless to config`, () => { + for (const mark of [BAR, AREA]) { + for (const channel of ['x', 'y'] as const) { + expect( + rules.zero( + channel, + {field: 'a', type: 'quantitative'}, + undefined, + {type: mark}, + 'linear', + {zero: false}, + false + ) + ).toBe(true); + } + } + }); + + it(`should return true for the continuous & quantitative size scale regardless to config`, () => { + expect( + rules.zero( + 'size', + {field: 'a', type: 'quantitative'}, + undefined, + {type: 'point'}, + 'linear', + {zero: false}, + false + ) + ).toBe(true); + }); }); });