Skip to content

Commit 5455906

Browse files
stephendeocad45
andauthored
Add Viz Extension Samples (#552)
* adding viz extension samples from extensions-api-preview * update paths in trex files * fixing d3 import that was accidently removed * updated packages used by samples * added global comments * cleaned up comments --------- Co-authored-by: Dave Hagen <[email protected]>
1 parent 7798298 commit 5455906

29 files changed

+2717
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Connected Scatterplot</title>
5+
6+
<!-- d3 -->
7+
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
8+
9+
<!-- Extensions Library -->
10+
<script src="../../../lib/tableau.extensions.1.latest.js"></script>
11+
12+
<!-- Our extension's code -->
13+
<script src="./connectedScatterplot.js"></script>
14+
15+
</head>
16+
<body>
17+
<div style="width: 100%; height: 100%; position: fixed" id="content"></div>
18+
</body>
19+
</html>
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
'use strict';
2+
/* global d3 */
3+
4+
// Wrap everything in an anonymous function to avoid polluting the global namespace
5+
(function () {
6+
window.onload = tableau.extensions.initializeAsync().then(() => {
7+
// Get the worksheet that the Viz Extension is running in
8+
const worksheet = tableau.extensions.worksheetContent.worksheet;
9+
10+
// Save these outside the scope below for handling resizing without refetching the data
11+
let summaryData = {};
12+
let encodingMap = {};
13+
14+
// Use the extensions API to get the summary data and map of encodings to fields,
15+
// and render the connected scatterplot.
16+
const updateDataAndRender = async () => {
17+
// Use extensions API to update the table of data and the map from encodings to fields
18+
[summaryData, encodingMap] = await Promise.all([
19+
getSummaryDataTable(worksheet),
20+
getEncodingMap(worksheet)
21+
]);
22+
23+
renderScatterplot(summaryData, encodingMap);
24+
};
25+
26+
// Handle re-rendering when the page is resized
27+
onresize = () => renderScatterplot(summaryData, encodingMap);
28+
29+
// Listen to event for when the summary data backing the worksheet has changed.
30+
// This tells us that we should refresh the data and encoding map.
31+
worksheet.addEventListener(
32+
tableau.TableauEventType.SummaryDataChanged,
33+
updateDataAndRender
34+
);
35+
36+
// Do the initial update and render
37+
updateDataAndRender();
38+
});
39+
40+
// Takes a page of data, which has a list of DataValues (dataTablePage.data)
41+
// and a list of columns and puts the data in a list where each entry is an
42+
// object that maps from field names to DataValues
43+
// (example of a row being: { SUM(Sales): ..., SUM(Profit): ..., Ship Mode: ..., })
44+
function convertToListOfNamedRows (dataTablePage) {
45+
const rows = [];
46+
const columns = dataTablePage.columns;
47+
const data = dataTablePage.data;
48+
for (let i = data.length - 1; i >= 0; --i) {
49+
const row = {};
50+
for (let j = 0; j < columns.length; ++j) {
51+
row[columns[j].fieldName] = data[i][columns[j].index];
52+
}
53+
rows.push(row);
54+
}
55+
return rows;
56+
}
57+
58+
// Gets each page of data in the summary data and returns a list of rows of data
59+
// associated with field names.
60+
async function getSummaryDataTable (worksheet) {
61+
let rows = [];
62+
63+
// Fetch the summary data using the DataTableReader
64+
const dataTableReader = await worksheet.getSummaryDataReaderAsync(
65+
undefined,
66+
{ ignoreSelection: true }
67+
);
68+
for (
69+
let currentPage = 0;
70+
currentPage < dataTableReader.pageCount;
71+
currentPage++
72+
) {
73+
const dataTablePage = await dataTableReader.getPageAsync(currentPage);
74+
rows = rows.concat(convertToListOfNamedRows(dataTablePage));
75+
}
76+
await dataTableReader.releaseAsync();
77+
78+
return rows;
79+
}
80+
81+
// Uses getVisualSpecificationAsync to build a map of encoding identifiers (specified in the .trex file)
82+
// to fields that the user has placed on the encoding's shelf.
83+
// Only encodings that have fields dropped on them will be part of the encodingMap.
84+
async function getEncodingMap (worksheet) {
85+
const visualSpec = await worksheet.getVisualSpecificationAsync();
86+
87+
const encodingMap = {};
88+
89+
if (visualSpec.activeMarksSpecificationIndex < 0) return encodingMap;
90+
91+
const marksCard =
92+
visualSpec.marksSpecifications[visualSpec.activeMarksSpecificationIndex];
93+
for (const encoding of marksCard.encodings) { encodingMap[encoding.id] = encoding.field; }
94+
95+
return encodingMap;
96+
}
97+
98+
// A convenience function for using a possibly undefined encoding to access something dependent on it being defined.
99+
function useOptionalEncoding (encoding, valFunc) {
100+
if (encoding) {
101+
return valFunc(encoding);
102+
}
103+
104+
return undefined;
105+
}
106+
107+
// Renders the scatterplot to the content area of the Viz Extensions given the data and mapping from encodings to fields.
108+
function renderScatterplot (data, encodings) {
109+
// Clear the content region before we render
110+
const content = document.getElementById('content');
111+
content.innerHTML = '';
112+
113+
const axisLabelPadding = 10;
114+
const animationMarkCountLimit = 1000;
115+
116+
// Render the ConnectedScatterplot using the data and the mapping of encodings to fields.
117+
// ConnectedScatterplot can render content when encodings are missing so pass in null
118+
// for an encoding when the user has not mapped a field to it
119+
const chart = ConnectedScatterplot(data, {
120+
x: (d) =>
121+
useOptionalEncoding(encodings.x, (encoding) => d[encoding.name].value),
122+
y: (d) =>
123+
useOptionalEncoding(encodings.y, (encoding) => d[encoding.name].value),
124+
title: (d) =>
125+
useOptionalEncoding(
126+
encodings.text,
127+
(encoding) => d[encoding.name].formattedValue
128+
),
129+
yFormat: '.2f',
130+
xLabel: useOptionalEncoding(encodings.x, (encoding) => encoding.name),
131+
yLabel: useOptionalEncoding(encodings.y, (encoding) => encoding.name),
132+
width: content.offsetWidth - axisLabelPadding,
133+
height: content.offsetHeight - axisLabelPadding,
134+
duration: data.length < animationMarkCountLimit ? 5000 : 0 // for the intro animation; 0 to disable
135+
});
136+
137+
content.appendChild(chart);
138+
}
139+
140+
// Below is a ConnectedScatterplot implementation that has been showcased in the d3 gallery.
141+
// Some slight modifications have been made to make it able to be used with WorkbookFormatting.
142+
143+
// Copyright 2021 Observable, Inc.
144+
// Released under the ISC license.
145+
// https://observablehq.com/@d3/connected-scatterplot
146+
function ConnectedScatterplot (
147+
data,
148+
{
149+
x = ([x]) => x, // given d in data, returns the (quantitative) x-value
150+
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
151+
r = 3, // (fixed) radius of dots, in pixels
152+
title, // given d in data, returns the label
153+
orient = () => 'top', // given d in data, returns a label orientation (top, right, bottom, left)
154+
defined, // for gaps in data
155+
curve = d3.curveCatmullRom, // curve generator for the line
156+
width = 640, // outer width, in pixels
157+
height = 400, // outer height, in pixels
158+
marginTop = 20, // top margin, in pixels
159+
marginRight = 20, // right margin, in pixels
160+
marginBottom = 30, // bottom margin, in pixels
161+
marginLeft = 60, // left margin, in pixels
162+
inset = r * 2, // inset the default range, in pixels
163+
insetTop = inset, // inset the default y-range
164+
insetRight = inset, // inset the default x-range
165+
insetBottom = inset, // inset the default y-range
166+
insetLeft = inset, // inset the default x-range
167+
xType = d3.scaleLinear, // type of x-scale
168+
xDomain, // [xmin, xmax]
169+
xRange = [marginLeft + insetLeft, width - marginRight - insetRight], // [left, right]
170+
xFormat, // a format specifier string for the x-axis
171+
xLabel, // a label for the x-axis
172+
yType = d3.scaleLinear, // type of y-scale
173+
yDomain, // [ymin, ymax]
174+
yRange = [height - marginBottom - insetBottom, marginTop + insetTop], // [bottom, top]
175+
yFormat, // a format specifier string for the y-axis
176+
yLabel, // a label for the y-axis
177+
fill = 'white', // fill color of dots
178+
stroke = 'currentColor', // stroke color of line and dots
179+
strokeWidth = 2, // stroke width of line and dots
180+
strokeLinecap = 'round', // stroke line cap of line
181+
strokeLinejoin = 'round', // stroke line join of line
182+
halo = '#fff', // halo color for the labels
183+
haloWidth = 6, // halo width for the labels
184+
duration = 0 // intro animation in milliseconds (0 to disable)
185+
} = {}
186+
) {
187+
// Compute values.
188+
const X = d3.map(data, x);
189+
const Y = d3.map(data, y);
190+
const T = title == null ? null : d3.map(data, title);
191+
const O = d3.map(data, orient);
192+
const I = d3.range(X.length);
193+
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
194+
const D = d3.map(data, defined);
195+
196+
// Compute default domains.
197+
if (xDomain === undefined) xDomain = d3.nice(...d3.extent(X), width / 80);
198+
if (yDomain === undefined) yDomain = d3.nice(...d3.extent(Y), height / 50);
199+
200+
// Construct scales and axes.
201+
const xScale = xType(xDomain, xRange);
202+
const yScale = yType(yDomain, yRange);
203+
const xAxis = d3.axisBottom(xScale).ticks(width / 80, xFormat);
204+
const yAxis = d3.axisLeft(yScale).ticks(height / 50, yFormat);
205+
206+
// Construct the line generator.
207+
const line = d3
208+
.line()
209+
.curve(curve)
210+
.defined((i) => D[i])
211+
.x((i) => xScale(X[i]))
212+
.y((i) => yScale(Y[i]));
213+
214+
const svg = d3
215+
.create('svg')
216+
.attr('width', width)
217+
.attr('height', height)
218+
.attr('viewBox', [0, 0, width, height])
219+
.attr('style', 'max-width: 100%; height: auto; height: intrinsic;')
220+
.attr('class', tableau.ClassNameKey.Worksheet); // Use Workbook Formatting settings for Worksheet
221+
222+
svg
223+
.append('g')
224+
.attr('transform', `translate(0,${height - marginBottom})`)
225+
.call(xAxis)
226+
.call((g) => g.select('.domain').remove())
227+
.call((g) =>
228+
g
229+
.selectAll('.tick line')
230+
.clone()
231+
.attr('y2', marginTop + marginBottom - height)
232+
.attr('stroke-opacity', 0.1)
233+
)
234+
.call((g) =>
235+
g
236+
.append('text')
237+
.attr('x', width)
238+
.attr('y', marginBottom - 4)
239+
.attr('fill', 'currentColor')
240+
.attr('text-anchor', 'end')
241+
.text(xLabel)
242+
);
243+
244+
svg
245+
.append('g')
246+
.attr('transform', `translate(${marginLeft},0)`)
247+
.call(yAxis)
248+
.call((g) => g.select('.domain').remove())
249+
.call((g) =>
250+
g
251+
.selectAll('.tick line')
252+
.clone()
253+
.attr('x2', width - marginLeft - marginRight)
254+
.attr('stroke-opacity', 0.1)
255+
)
256+
.call((g) =>
257+
g
258+
.append('text')
259+
.attr('x', -marginLeft)
260+
.attr('y', 10)
261+
.attr('fill', 'currentColor')
262+
.attr('text-anchor', 'start')
263+
.text(yLabel)
264+
);
265+
266+
const path = svg
267+
.append('path')
268+
.attr('fill', 'none')
269+
.attr('stroke', stroke)
270+
.attr('stroke-width', strokeWidth)
271+
.attr('stroke-linejoin', strokeLinejoin)
272+
.attr('stroke-linecap', strokeLinecap)
273+
.attr('d', line(I));
274+
275+
svg
276+
.append('g')
277+
.attr('fill', fill)
278+
.attr('stroke', stroke)
279+
.attr('stroke-width', strokeWidth)
280+
.selectAll('circle')
281+
.data(I.filter((i) => D[i]))
282+
.join('circle')
283+
.attr('cx', (i) => xScale(X[i]))
284+
.attr('cy', (i) => yScale(Y[i]))
285+
.attr('r', r);
286+
287+
const label = svg
288+
.append('g')
289+
.attr('stroke-linejoin', 'round')
290+
.selectAll('g')
291+
.data(I.filter((i) => D[i]))
292+
.join('g')
293+
.attr('transform', (i) => `translate(${xScale(X[i])},${yScale(Y[i])})`);
294+
295+
if (T) {
296+
label
297+
.append('text')
298+
.text((i) => T[i])
299+
.each(function (i) {
300+
const t = d3.select(this);
301+
switch (O[i]) {
302+
case 'bottom':
303+
t.attr('text-anchor', 'middle').attr('dy', '1.4em');
304+
break;
305+
case 'left':
306+
t.attr('dx', '-0.5em')
307+
.attr('dy', '0.32em')
308+
.attr('text-anchor', 'end');
309+
break;
310+
case 'right':
311+
t.attr('dx', '0.5em')
312+
.attr('dy', '0.32em')
313+
.attr('text-anchor', 'start');
314+
break;
315+
default:
316+
t.attr('text-anchor', 'middle').attr('dy', '-0.7em');
317+
break;
318+
}
319+
})
320+
.call((text) => text.clone(true))
321+
.attr('fill', 'none')
322+
.attr('stroke', halo)
323+
.attr('stroke-width', haloWidth);
324+
}
325+
326+
// Measure the length of the given SVG path string.
327+
function length (path) {
328+
return d3.create('svg:path').attr('d', path).node().getTotalLength();
329+
}
330+
331+
function animate () {
332+
if (duration > 0) {
333+
const l = length(line(I));
334+
335+
path
336+
.interrupt()
337+
.attr('stroke-dasharray', `0,${l}`)
338+
.transition()
339+
.duration(duration)
340+
.ease(d3.easeLinear)
341+
.attr('stroke-dasharray', `${l},${l}`);
342+
343+
label
344+
.interrupt()
345+
.attr('opacity', 0)
346+
.transition()
347+
.delay(
348+
(i) =>
349+
(length(line(I.filter((j) => j <= i))) / l) * (duration - 125)
350+
)
351+
.attr('opacity', 1);
352+
}
353+
}
354+
355+
animate();
356+
357+
return Object.assign(svg.node(), { animate });
358+
}
359+
})();

0 commit comments

Comments
 (0)