diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 73e6934fd1..b28ff8edc9 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -827,6 +827,54 @@ "$ref": "#/definitions/CompositeUnitSpecAlias", "description": "Unit spec that can have a composite mark." }, + "Conditional": { + "additionalProperties": false, + "properties": { + "aggregate": { + "$ref": "#/definitions/Aggregate", + "description": "Aggregation function for the field\n(e.g., `mean`, `sum`, `median`, `min`, `max`, `count`).\n\n__Default value:__ `undefined` (None)" + }, + "bin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/BinParams" + } + ], + "description": "A flag for binning a `quantitative` field, or [an object defining binning parameters](bin.html#params).\nIf `true`, default [binning parameters](bin.html) will be applied.\n\n__Default value:__ `false`" + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/RepeatRef" + } + ], + "description": "__Required.__ A string defining the name of the field from which to pull a data value\nor an object defining iterated values from the [`repeat`](repeat.html) operator.\n\n__Note:__ Dots (`.`) and brackets (`[` and `]`) can be used to access nested objects (e.g., `\"field\": \"foo.bar\"` and `\"field\": \"foo['bar']\"`).\nIf field names contain dots or brackets but are not nested, you can use `\\\\` to escape dots and brackets (e.g., `\"a\\\\.b\"` and `\"a\\\\[0\\\\]\"`).\nSee more details about escaping in the [field documentation](field.html).\n\n__Note:__ `field` is not required if `aggregate` is `count`." + }, + "selection": { + "$ref": "#/definitions/SelectionOperand", + "description": "A [selection name](selection.html), or a series of [composed selections](selection.html#compose)." + }, + "timeUnit": { + "$ref": "#/definitions/TimeUnit", + "description": "Time unit (e.g., `year`, `yearmonth`, `month`, `hours`) for a temporal field.\nor [a temporal field that gets casted as ordinal](type.html#cast).\n\n__Default value:__ `undefined` (None)" + }, + "type": { + "$ref": "#/definitions/Type", + "description": "The encoded field's type of measurement (`\"quantitative\"`, `\"temporal\"`, `\"ordinal\"`, or `\"nominal\"`)." + } + }, + "required": [ + "selection", + "type" + ], + "type": "object" + }, "Conditional": { "additionalProperties": false, "properties": { @@ -1303,6 +1351,17 @@ ], "description": "Additional levels of detail for grouping data in aggregate views and\nin line and area marks without mapping data to a specific visual channel." }, + "href": { + "anyOf": [ + { + "$ref": "#/definitions/FieldDefWithCondition" + }, + { + "$ref": "#/definitions/ValueDefWithCondition" + } + ], + "description": "A URL to load upon mouse click." + }, "opacity": { "anyOf": [ { @@ -1451,6 +1510,17 @@ ], "description": "Additional levels of detail for grouping data in aggregate views and\nin line and area marks without mapping data to a specific visual channel." }, + "href": { + "anyOf": [ + { + "$ref": "#/definitions/FieldDefWithCondition" + }, + { + "$ref": "#/definitions/ValueDefWithCondition" + } + ], + "description": "A URL to load upon mouse click." + }, "opacity": { "anyOf": [ { @@ -1715,6 +1785,64 @@ ], "type": "object" }, + "FieldDefWithCondition": { + "additionalProperties": false, + "description": "A FieldDef with Condition\n{\n condition: {value: ...},\n field: ...,\n ...\n}", + "properties": { + "aggregate": { + "$ref": "#/definitions/Aggregate", + "description": "Aggregation function for the field\n(e.g., `mean`, `sum`, `median`, `min`, `max`, `count`).\n\n__Default value:__ `undefined` (None)" + }, + "bin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/BinParams" + } + ], + "description": "A flag for binning a `quantitative` field, or [an object defining binning parameters](bin.html#params).\nIf `true`, default [binning parameters](bin.html) will be applied.\n\n__Default value:__ `false`" + }, + "condition": { + "anyOf": [ + { + "$ref": "#/definitions/Conditional" + }, + { + "items": { + "$ref": "#/definitions/Conditional" + }, + "type": "array" + } + ], + "description": "One or more value definition(s) with a selection predicate.\n\n__Note:__ A field definition's `condition` property can only contain [value definitions](encoding.html#value-def)\nsince Vega-Lite only allows at mosty one encoded field per encoding channel." + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/RepeatRef" + } + ], + "description": "__Required.__ A string defining the name of the field from which to pull a data value\nor an object defining iterated values from the [`repeat`](repeat.html) operator.\n\n__Note:__ Dots (`.`) and brackets (`[` and `]`) can be used to access nested objects (e.g., `\"field\": \"foo.bar\"` and `\"field\": \"foo['bar']\"`).\nIf field names contain dots or brackets but are not nested, you can use `\\\\` to escape dots and brackets (e.g., `\"a\\\\.b\"` and `\"a\\\\[0\\\\]\"`).\nSee more details about escaping in the [field documentation](field.html).\n\n__Note:__ `field` is not required if `aggregate` is `count`." + }, + "timeUnit": { + "$ref": "#/definitions/TimeUnit", + "description": "Time unit (e.g., `year`, `yearmonth`, `month`, `hours`) for a temporal field.\nor [a temporal field that gets casted as ordinal](type.html#cast).\n\n__Default value:__ `undefined` (None)" + }, + "type": { + "$ref": "#/definitions/Type", + "description": "The encoded field's type of measurement (`\"quantitative\"`, `\"temporal\"`, `\"ordinal\"`, or `\"nominal\"`)." + } + }, + "required": [ + "type" + ], + "type": "object" + }, "MarkPropFieldDefWithCondition": { "additionalProperties": false, "description": "A FieldDef with Condition\n{\n condition: {value: ...},\n field: ...,\n ...\n}", @@ -4512,7 +4640,8 @@ "color", "opacity", "text", - "tooltip" + "tooltip", + "href" ], "type": "string" }, @@ -5831,6 +5960,38 @@ ], "type": "object" }, + "ValueDefWithCondition": { + "additionalProperties": false, + "description": "A ValueDef with Condition\n{\n condition: {field: ...} | {value: ...},\n value: ...,\n}", + "properties": { + "condition": { + "anyOf": [ + { + "$ref": "#/definitions/Conditional" + }, + { + "$ref": "#/definitions/Conditional" + }, + { + "items": { + "$ref": "#/definitions/Conditional" + }, + "type": "array" + } + ], + "description": "A field definition or one or more value definition(s) with a selection predicate." + }, + "value": { + "description": "A constant value in visual domain.", + "type": [ + "number", + "string", + "boolean" + ] + } + }, + "type": "object" + }, "MarkPropValueDefWithCondition": { "additionalProperties": false, "description": "A ValueDef with Condition\n{\n condition: {field: ...} | {value: ...},\n value: ...,\n}", diff --git a/scripts/rename-schema.sh b/scripts/rename-schema.sh index 5beb9c08d3..472daed2b6 100755 --- a/scripts/rename-schema.sh +++ b/scripts/rename-schema.sh @@ -12,6 +12,9 @@ perl -pi -e s,'GenericHConcatSpec','HConcatSpec',g build/vega perl -pi -e s,'GenericUnitSpec','FacetedCompositeUnitSpecAlias',g build/vega-lite-schema.json perl -pi -e s,'GenericUnitSpec','CompositeUnitSpecAlias',g build/vega-lite-schema.json +perl -pi -e s,'FieldDefWithCondition','FieldDefWithCondition',g build/vega-lite-schema.json +perl -pi -e s,'ValueDefWithCondition','ValueDefWithCondition',g build/vega-lite-schema.json + perl -pi -e s,'FieldDefWithCondition','TextFieldDefWithCondition',g build/vega-lite-schema.json perl -pi -e s,'ValueDefWithCondition','TextValueDefWithCondition',g build/vega-lite-schema.json diff --git a/site/docs/encoding.md b/site/docs/encoding.md index 11363b1ea3..ba70493e00 100644 --- a/site/docs/encoding.md +++ b/site/docs/encoding.md @@ -30,6 +30,9 @@ The `encoding` property of a single view specification represents the mapping be "text": ..., "tooltip": ..., + // Hyperlink Channel + "href": ..., + // Order Channel "order": ..., @@ -51,7 +54,8 @@ The keys in the `encoding` object are encoding channels. Vega-lite supports the - [Position Channels](#position): `x`, `y`, `x2`, `y2` - [Mark Property Channels](#mark-prop): `color`, `opacity`, `shape`, `size` -- [Text and Tooltip Channels](#text): `text`, `tooltip` +- [Text and Tooltip Channels](#text): `text`, `tooltip` +- [Hyperlink Channel](#href): `href` - [Level of Detail Channel](#detail): `detail` - [Order Channel](#order): `order` - [Facet Channels](#facet): `row`, `column` @@ -204,6 +208,29 @@ In addition to the constant `value`, [value definitions](#value-def) of `text` a {% include table.html props="condition" source="TextValueDefWithCondition" %} +{:#href} +## Hyperlink Channel + +By setting the `href` channel, a mark becomes a hyperlink. The specified URL is loaded upon a muse click. The `cursor` mark property can be set to `pointer` to serve as affordance for hyperlinks. + +{% include table.html props="href" source="Encoding" %} + + +{:#href-field-def} +### Hyperlink Field Definition + +In addition to [`field`](field.html), [`type`](type.html), [`bin`](bin.html), [`timeUnit`](timeunit.html) and [`aggregate`](aggregate.html), +[field definitions](#field-def) for the `href` channel can include the `condition` property to specify conditional logic. + +{% include table.html props="condition" source="FieldDefWithCondition" %} + +{:#href-value-def} +### Hyperlink Value Definition + +In addition to the constant `value`, [value definitions](#value-def) of the `href` channel can include the `condition` property to specify conditional logic. + +{% include table.html props="condition" source="ValueDefWithCondition" %} + {:#detail} ## Level of Detail Channel diff --git a/src/channel.ts b/src/channel.ts index 46e25808d2..24c8cba9d2 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -32,6 +32,7 @@ export namespace Channel { export const ORDER: 'order' = 'order'; export const DETAIL: 'detail' = 'detail'; export const TOOLTIP: 'tooltip' = 'tooltip'; + export const HREF: 'href' = 'href'; } export type Channel = keyof Encoding | keyof FacetMapping; @@ -50,6 +51,7 @@ export const DETAIL = Channel.DETAIL; export const ORDER = Channel.ORDER; export const OPACITY = Channel.OPACITY; export const TOOLTIP = Channel.TOOLTIP; +export const HREF = Channel.HREF; const UNIT_CHANNEL_INDEX: Flag> = { x: 1, @@ -63,7 +65,8 @@ const UNIT_CHANNEL_INDEX: Flag> = { opacity: 1, text: 1, detail: 1, - tooltip: 1 + tooltip: 1, + href: 1, }; const FACET_CHANNEL_INDEX: Flag> = { @@ -93,7 +96,7 @@ export const SINGLE_DEF_CHANNELS: SingleDefChannel[] = flagKeys(SINGLE_DEF_CHANN // Using the following line leads to TypeError: Cannot read property 'elementTypes' of undefined // when running the schema generator // export type SingleDefChannel = typeof SINGLE_DEF_CHANNELS[0]; -export type SingleDefChannel = 'x' | 'y' | 'x2' | 'y2' | 'row' | 'column' | 'size' | 'shape' | 'color' | 'opacity' | 'text' | 'tooltip'; +export type SingleDefChannel = 'x' | 'y' | 'x2' | 'y2' | 'row' | 'column' | 'size' | 'shape' | 'color' | 'opacity' | 'text' | 'tooltip' | 'href'; @@ -124,9 +127,9 @@ export type PositionScaleChannel = typeof POSITION_SCALE_CHANNELS[0]; // NON_POSITION_SCALE_CHANNEL = SCALE_CHANNELS without X, Y const { - // x2 and y2 share the same scale as x and y - // text and tooltip has format instead of scale - text: _t, tooltip: _tt, + // x2 and y2 share the same scale as x and y + // text, tooltip, and href have format instead of scale + text: _t, tooltip: _tt, href: _hr, // detail and order have no scale detail: _dd, order: _oo, ...NONPOSITION_SCALE_CHANNEL_INDEX @@ -159,7 +162,6 @@ export interface SupportedMark { line?: boolean; area?: boolean; text?: boolean; - tooltip?: boolean; } /** @@ -184,6 +186,7 @@ export function getSupportedMark(channel: Channel): SupportedMark { case COLOR: case DETAIL: case TOOLTIP: + case HREF: case ORDER: // TODO: revise (order might not support rect, which is not stackable?) case OPACITY: case ROW: @@ -223,9 +226,10 @@ export function rangeType(channel: Channel): RangeType { case ROW: case COLUMN: case SHAPE: - // TEXT and TOOLTIP have no scale but have discrete output + // TEXT, TOOLTIP, and HREF have no scale but have discrete output case TEXT: case TOOLTIP: + case HREF: return 'discrete'; // Color can be either continuous or discrete, depending on scale type. diff --git a/src/compile/mark/area.ts b/src/compile/mark/area.ts index 92035b1019..b0a6d60a89 100644 --- a/src/compile/mark/area.ts +++ b/src/compile/mark/area.ts @@ -14,6 +14,7 @@ export const area: MarkCompiler = { ...mixins.color(model), ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('opacity', model), }; } diff --git a/src/compile/mark/bar.ts b/src/compile/mark/bar.ts index 3bc755c39e..c2d0532fc6 100644 --- a/src/compile/mark/bar.ts +++ b/src/compile/mark/bar.ts @@ -24,6 +24,7 @@ export const bar: MarkCompiler = { ...y(model, stack), ...mixins.color(model), ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('opacity', model) }; } diff --git a/src/compile/mark/line.ts b/src/compile/mark/line.ts index 05eb69f058..56c62cc8e3 100644 --- a/src/compile/mark/line.ts +++ b/src/compile/mark/line.ts @@ -15,6 +15,7 @@ export const line: MarkCompiler = { ...mixins.pointPosition('y', model, ref.mid(height)), ...mixins.color(model), ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('opacity', model), ...mixins.nonPosition('size', model, { vgChannel: 'strokeWidth' // VL's line size is strokeWidth diff --git a/src/compile/mark/mark.ts b/src/compile/mark/mark.ts index 6fefcfb690..d7bb85eeda 100644 --- a/src/compile/mark/mark.ts +++ b/src/compile/mark/mark.ts @@ -154,9 +154,10 @@ export function pathGroupingFields(encoding: Encoding): string[] { case 'y': case 'order': case 'tooltip': + case 'href': case 'x2': case 'y2': - // TODO: case 'href', 'cursor': + // TODO: case 'cursor': // text, shape, shouldn't be a part of line/area case 'text': diff --git a/src/compile/mark/mixins.ts b/src/compile/mark/mixins.ts index 197f1ecda3..614970dd56 100644 --- a/src/compile/mark/mixins.ts +++ b/src/compile/mark/mixins.ts @@ -100,7 +100,7 @@ function wrapCondition( } } -export function text(model: UnitModel, channel: 'text' | 'tooltip' = 'text') { +export function text(model: UnitModel, channel: 'text' | 'tooltip' | 'href' = 'text') { const channelDef = model.encoding[channel]; return wrapCondition(model, channelDef, channel, (cDef) => ref.text(cDef, model.config)); } diff --git a/src/compile/mark/point.ts b/src/compile/mark/point.ts index cba61122f2..abf9f56232 100644 --- a/src/compile/mark/point.ts +++ b/src/compile/mark/point.ts @@ -17,6 +17,7 @@ function encodeEntry(model: UnitModel, fixedShape?: 'circle' | 'square') { ...mixins.color(model), ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('size', model), ...shapeMixins(model, config, fixedShape), ...mixins.nonPosition('opacity', model), diff --git a/src/compile/mark/rect.ts b/src/compile/mark/rect.ts index b69f4c2099..77d36bfc80 100644 --- a/src/compile/mark/rect.ts +++ b/src/compile/mark/rect.ts @@ -17,6 +17,7 @@ export const rect: MarkCompiler = { ...y(model), ...mixins.color(model), ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('opacity', model), }; } diff --git a/src/compile/mark/rule.ts b/src/compile/mark/rule.ts index d8e88d4c86..2aff4237e3 100644 --- a/src/compile/mark/rule.ts +++ b/src/compile/mark/rule.ts @@ -23,6 +23,7 @@ export const rule: MarkCompiler = { ...mixins.color(model), ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('opacity', model), ...mixins.nonPosition('size', model, { vgChannel: 'strokeWidth' // VL's rule size is strokeWidth diff --git a/src/compile/mark/text.ts b/src/compile/mark/text.ts index e875f0a27b..18662a27b3 100644 --- a/src/compile/mark/text.ts +++ b/src/compile/mark/text.ts @@ -26,6 +26,7 @@ export const text: MarkCompiler = { ...mixins.text(model), ...mixins.color(model), ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('opacity', model), ...mixins.nonPosition('size', model, { vgChannel: 'fontSize' // VL's text size is fontSize diff --git a/src/compile/mark/tick.ts b/src/compile/mark/tick.ts index 9f4b63c427..b6dd450d73 100644 --- a/src/compile/mark/tick.ts +++ b/src/compile/mark/tick.ts @@ -28,6 +28,8 @@ export const tick: MarkCompiler = { [vgThicknessChannel]: {value: config.tick.thickness}, ...mixins.color(model), + ...mixins.text(model, 'tooltip'), + ...mixins.text(model, 'href'), ...mixins.nonPosition('opacity', model), }; } diff --git a/src/encoding.ts b/src/encoding.ts index 750a30420c..8f68654660 100644 --- a/src/encoding.ts +++ b/src/encoding.ts @@ -98,6 +98,11 @@ export interface Encoding { */ tooltip?: FieldDefWithCondition> | ValueDefWithCondition>; + /** + * A URL to load upon mouse click. + */ + href?: FieldDefWithCondition> | ValueDefWithCondition>; + /** * Stack order for stacked marks or order of data points in line marks for connected scatter plots. * diff --git a/src/fielddef.ts b/src/fielddef.ts index daf2ec9a2c..9dc0c7997e 100644 --- a/src/fielddef.ts +++ b/src/fielddef.ts @@ -535,6 +535,7 @@ export function channelCompatibility(fieldDef: FieldDef, channel: Channel case 'text': case 'detail': case 'tooltip': + case 'href': return COMPATIBLE; case 'opacity': diff --git a/test/channel.test.ts b/test/channel.test.ts index b1b787daaa..f22ca07c48 100644 --- a/test/channel.test.ts +++ b/test/channel.test.ts @@ -18,7 +18,7 @@ describe('channel', () => { describe('SCALE_CHANNELS', () => { it('should be UNIT_CHANNELS without X2, Y2, ORDER, DETAIL, TEXT, LABEL, TOOLTIP', () => { - assert.deepEqual(SCALE_CHANNELS, without(UNIT_CHANNELS, ['x2', 'y2', 'order', 'detail', 'text', 'label', 'tooltip'])); + assert.deepEqual(SCALE_CHANNELS, without(UNIT_CHANNELS, ['x2', 'y2', 'order', 'detail', 'text', 'label', 'tooltip', 'href'])); }); }); diff --git a/test/compile/mark/mark.test.ts b/test/compile/mark/mark.test.ts index 4449ddc52e..3dff6e74a0 100644 --- a/test/compile/mark/mark.test.ts +++ b/test/compile/mark/mark.test.ts @@ -120,6 +120,21 @@ describe('Mark', function() { assert.equal(markGroup[0].encode.update.tooltip.value, 'foo'); }); }); + + describe('Bar with href', () => { + it('should pass href value to encoding', () => { + const model = parseUnitModelWithScaleAndLayoutSize({ + "mark": "bar", + "encoding": { + "x": {"type": "quantitative", "field": "Cost__Other", "aggregate": "sum"}, + "y": {"bin": true, "type": "quantitative", "field": "Cost__Total_$"}, + "href": {"value": "https://idl.cs.washington.edu/"} + } + }); + const markGroup = parseMarkGroup(model); + assert.equal(markGroup[0].encode.update.href.value, 'https://idl.cs.washington.edu/'); + }); + }); }); describe('getPathSort', () => {