-
Notifications
You must be signed in to change notification settings - Fork 1
/
template.js
464 lines (401 loc) · 14.7 KB
/
template.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
import { select } from "d3-selection";
import { TemplateNode, RepeatNode, IfNode, WithNode, ImportNode } from "./template-node";
import { AttributeRenderer, StyleRenderer, PropertyRenderer, ClassRenderer, TextRenderer } from "./renderer";
// ---- Defaults ----
var defaults = {
repeatAttribute: "data-repeat",
ifAttribute: "data-if",
withAttribute: "data-with",
importAttribute: "data-import",
indirectAttributePrefix: "data-attr-",
indirectStylePrefix: "data-style-",
indirectPropertyPrefix: "data-prop-",
indirectClassPrefix: "data-class-"
};
// ---- Fix for IE (small kneefall because difficult to fix otherwise) ----
var REG_EX_FLAG = "";
/* istanbul ignore next */
try { if((new RegExp(".*", "u")).unicode) { REG_EX_FLAG = "u"; } } catch(e) { /* Ignore */ }
// ---- Constants ----
var FIELD_SELECTOR_REG_EX = new RegExp("^\\s*\\{\\{\\s*(.*)\\s*\\}\\}\\s*$", REG_EX_FLAG);
var ALL_DIRECT_CHILDREN = function() { return this.children; };
var SVG_CAMEL_CASE_ATTRS = {}; // Combined SVG 1.1 and SVG 2 (draft 14 feb 2018)
[
"attributeName",
"attributeType",
"baseFrequency",
"baseProfile",
"calcMode",
"clipPathUnits",
"contentScriptType",
"contentStyleType",
"diffuseConstant",
"edgeMode",
"externalResourcesRequired",
"filterRes",
"filterUnits",
"glyphRef",
"gradientTransform",
"gradientUnits",
"hatchContentUnits",
"hatchUnits",
"kernelMatrix",
"kernelUnitLength",
"keyPoints",
"keySplines",
"keyTimes",
"lengthAdjust",
"limitingConeAngle",
"markerHeight",
"markerUnits",
"markerWidth",
"maskContentUnits",
"maskUnits",
"numOctaves",
"pathLength",
"patternContentUnits",
"patternTransform",
"patternUnits",
"pointsAtX",
"pointsAtY",
"pointsAtZ",
"preserveAlpha",
"preserveAspectRatio",
"primitiveUnits",
"refX",
"refY",
"repeatCount",
"repeatDur",
"requiredExtensions",
"requiredFeatures",
"specularConstant",
"specularExponent",
"spreadMethod",
"startOffset",
"stdDeviation",
"stitchTiles",
"surfaceScale",
"systemLanguage",
"tableValues",
"targetX",
"targetY",
"textLength",
"viewBox",
"viewTarget",
"xChannelSelector",
"yChannelSelector",
"zoomAndPan"
].forEach(function(attributeName) {
SVG_CAMEL_CASE_ATTRS[attributeName.toLowerCase()] = attributeName;
});
// ---- Main functions ----
// Create template from receiver (this method will be added to the d3 selection prototype)
export function selection_template(options) {
return template(this, options);
}
// Create template from the specified selection
export function template(selection, options) {
// Decide to use options or defaults
options = Object.assign({}, defaults, options || {});
// Store regular expressions for accessing indirect attributes
options.indirectAttributeRegEx = new RegExp("^" + options.indirectAttributePrefix + "(.*)$", REG_EX_FLAG);
options.indirectStyleRegEx = new RegExp("^" + options.indirectStylePrefix + "(.*)$", REG_EX_FLAG);
options.indirectPropertyRegEx = new RegExp("^" + options.indirectPropertyPrefix + "(.*)$", REG_EX_FLAG);
options.indirectClassRegEx = new RegExp("^" + options.indirectClassPrefix + "(.*)$", REG_EX_FLAG);
// Create templates from the current selection
selection.each(function() {
var rootElement = select(this);
// Check for existing template
var templateNode = TemplateNode.getTemplateNode(rootElement);
if(templateNode) {
throw new Error("Templates should not overlap. Use 'import' here.");
} else {
// Create a template root node for the element
var rootNode = new TemplateNode(rootElement);
// Create template parser using specified identification mechanism
var templateParser = new TemplateParser(options);
// Add renderers to root node so template can be rendered when data is provided
templateParser.parse(rootElement, rootNode);
}
});
return selection;
}
// Render data on selection
export function selection_render(data) {
return render(this, data);
}
// Render data on transition
export function transition_render(data) {
var self = this;
this.on("start.d3t7", function() {
render(self, data);
});
return this;
}
// Render data on specified selection (selection should consist of a template)
// If selection consists of multiple elements, the same data is rendered on all
// elements.
export function render(selectionOrTransition, data) {
// Render templates in the current selection
var transition = selectionOrTransition.duration !== undefined ? selectionOrTransition : null;
selectionOrTransition.each(function() {
var element = select(this);
// Validate that element is a template node (which can be rendered)
if(!TemplateNode.isTemplateNode(element)) {
throw new Error("Method render() called on non-template selection.");
}
// Retrieve template for element
var templateNode = TemplateNode.getTemplateNode(element);
// Join data and render template
templateNode.renderData(data, element, transition);
});
return selectionOrTransition;
}
// ---- TemplateParser class ----
function TemplateParser(options) {
this.options = options;
}
// ---- TemplateParser class methods ----
// Answer a fixed (ie non live) list of attributes for the specified element
TemplateParser.getAttributesFor = function(element) {
var attributes = [];
if(element.node().hasAttributes()) {
var attributeMap = element.node().attributes;
for(var i = 0; i < attributeMap.length; i++) {
var name = attributeMap[i].name;
var localName;
var prefix;
var separatorIndex = name.indexOf(":");
if(separatorIndex >= 0) {
prefix = name.slice(0, separatorIndex);
localName = name.slice(separatorIndex + 1);
} else {
prefix = undefined;
localName = name;
}
attributes.push({
prefix: prefix,
localName: localName,
name: name,
value: attributeMap[i].value
});
}
}
return attributes;
};
// Answer a data function based on the specified expression
TemplateParser.createDataFunction = function(expression) {
// Check tags for data function
var isTweenFunction = false;
if(expression.startsWith("tween:")) {
isTweenFunction = true;
expression = expression.slice(6);
}
// Create data function
var dataFunction;
try {
var functionBody = "return " + expression.trim();
dataFunction = new Function("d", "i", "nodes", functionBody);
} catch(e) {
throw new Error("Invalid expression \"" + expression + "\". Error: " + e.message);
}
// Add tween indicator (if applicable)
if(isTweenFunction) {
dataFunction.isTweenFunction = true;
}
return dataFunction;
};
// ---- TemplateParser instance methods ----
// Parse template by adding template nodes and renderers for the specified element to specified template node
TemplateParser.prototype.parse = function(element, templateNode) {
// Validate templates do not overlap
if(TemplateNode.isTemplateNode(element) && TemplateNode.getTemplateNode(element) !== templateNode) {
throw new Error("Templates should not overlap. Use 'import' here.");
}
// Add template nodes for groupings and renderers for attributes and text (order is important!)
this.parseGroupingNodes(element, templateNode);
this.parseAttributeRenderers(element, templateNode);
this.parseTextRenderers(element, templateNode);
// Process all direct children recursively
var self = this;
element.selectAll(ALL_DIRECT_CHILDREN).each(function() {
var childElement = select(this);
self.parse(childElement, templateNode);
});
};
// Parse template by adding grouping nodes (like repeat, if, with or import) for the specified element to specified template node
TemplateParser.prototype.parseGroupingNodes = function(element, templateNode) {
// Handle grouping nodes
var groupings = [
{ attr: this.options.repeatAttribute, nodeClass: RepeatNode, match: false },
{ attr: this.options.ifAttribute, nodeClass: IfNode, match: false },
{ attr: this.options.withAttribute, nodeClass: WithNode, match: false },
{ attr: this.options.importAttribute, nodeClass: ImportNode, match: false }
];
var withGrouping = groupings[2];
var importGrouping = groupings[3];
// Collect grouping node info from element
groupings.forEach(function(grouping) {
var field = element.attr(grouping.attr);
if(field) {
grouping.match = field.match(FIELD_SELECTOR_REG_EX);
}
});
// Validate there is 0 or 1 match (handle import separately)
groupings = groupings.filter(function(grouping) { return grouping !== importGrouping && grouping.match; });
if(groupings.length > 1) {
throw new Error("A repeat, if or with grouping can't be combined on same element. Wrap one in the other.");
}
// Handle import specifically
// Import can only be combined with the with-grouping. If with-grouping is present
// use its data function for the import (or otherwise the default pass through data
// function).
if(importGrouping.match) {
if(groupings.length === 1 && !withGrouping.match) {
throw new Error("A repeat or if grouping can't be combined with import on the same element. Wrap one in the other.");
}
if(element.node().children.length > 0) {
throw new Error("No child elements allowed within an import grouping.");
}
if(element.text().trim().length !== 0) {
throw new Error("No text allowed within an import grouping.");
}
// Set groupings to only contain the import grouping
importGrouping.importSelector = importGrouping.match[1];
importGrouping.match = withGrouping.match ? withGrouping.match : [ "{{d}}", "d" ];
groupings = [ importGrouping ];
}
// Handle grouping
if(groupings.length === 1) {
// Select and extract first child as the grouping element
var childElement = element
.select(function() { return this.firstElementChild; })
.remove()
;
// Text should be wrapped inside element
if(childElement.size() === 0 && element.text().trim().length !== 0) {
throw new Error("A child element should be present within repeat, if or with grouping. Wrap text in a DOM element.");
}
// Additional children are not allowed
if(element.node().children.length > 0) {
throw new Error("Only a single child element allowed within repeat, if or with grouping. Wrap child elements in a container element.");
}
// Set child element to the child element selector in case of import
// For import there can't be a child so it is okay to 'overwrite' it.
if(importGrouping.importSelector) {
childElement = TemplateParser.createDataFunction(importGrouping.importSelector);
}
// Add grouping nodes
var grouping = groupings[0];
var groupingNode = new grouping.nodeClass(
element,
TemplateParser.createDataFunction(grouping.match[1]),
childElement
);
templateNode.addChildNode(groupingNode);
// Remove grouping attribute(s)
element.attr(grouping.attr, null);
if(withGrouping.match) {
element.attr(withGrouping.attr, null);
}
// Add template nodes and renderers for the grouping element's child (except for import)
if(!importGrouping.importSelector) {
if(childElement.size() === 1) {
this.parse(childElement, groupingNode);
// Store event handlers in the grouping node (it will be used there).
// This is only relevant for childElement since it is removed
// from the DOM and will later be 'created' again during a data join.
// Because the child might be (re)created multiple times (in a repeat)
// through a 'clone' operation, the event handlers will be lost.
// Storing these explictly will allow the event handlers to be
// present on all child elements.
// At this point any (sub)groupings of childElement are removed,
// so childElement is 'cleaned' from further child elements from
// another grouping and events will be stored only once.
groupingNode.storeEventHandlers(childElement);
}
}
}
};
// Parse template by adding attribute renderers (include attributes referring to style properties) for the specified element to specified template node
TemplateParser.prototype.parseAttributeRenderers = function(element, templateNode) {
// Create a fixed (ie non live) list of attributes
// (since attributes will be removed during processing)
var attributes = TemplateParser.getAttributesFor(element);
// Handle attributes (and styles)
var options = this.options;
attributes.forEach(function(attribute) {
// Check if field selector is present
var match = attribute.value.match(FIELD_SELECTOR_REG_EX);
if(match) {
// Decide which attribute/style will be rendered
var renderClass = AttributeRenderer;
var renderAttributeName = attribute.localName;
var nameMatch = renderAttributeName.match(options.indirectAttributeRegEx);
if(nameMatch) {
renderAttributeName = nameMatch[1]; // Render the referenced attribute
// Fix camel case for some SVG attribute names
// data-* attributes are lowercase according to specification (also for SVG).
// Remap these to there camelCase variant if applied on SVG element.
if(element.node().ownerSVGElement !== undefined) {
var camelCaseAttributeName = SVG_CAMEL_CASE_ATTRS[renderAttributeName];
if(camelCaseAttributeName) {
renderAttributeName = camelCaseAttributeName;
}
}
} else {
nameMatch = renderAttributeName.match(options.indirectStyleRegEx);
if(nameMatch) {
renderAttributeName = nameMatch[1]; // Render the referenced style
renderClass = StyleRenderer;
} else {
nameMatch = renderAttributeName.match(options.indirectPropertyRegEx);
if(nameMatch) {
renderAttributeName = nameMatch[1]; // Render the referenced property
renderClass = PropertyRenderer;
} else {
nameMatch = renderAttributeName.match(options.indirectClassRegEx);
if(nameMatch) {
renderAttributeName = nameMatch[1]; // Render the referenced class name
renderClass = ClassRenderer;
}
}
}
}
// Re-apply namespace
if(attribute.prefix) {
renderAttributeName = attribute.prefix + ":" + renderAttributeName;
}
// Add renderer
templateNode.addRenderer(new renderClass(
element,
TemplateParser.createDataFunction(match[1]),
renderAttributeName
));
// Remove attribute
if(nameMatch && attribute.prefix) {
// Special case: when attribute is indirect the namespace is not
// recognised as such and needs to be removed as a normal attribute.
element.node().removeAttribute(attribute.name);
} else {
element.attr(attribute.name, null);
}
}
});
};
// Parse template by adding text renderers for the specified element to specified template node
TemplateParser.prototype.parseTextRenderers = function(element, templateNode) {
// Handle text nodes (no other children allowed on these elements)
if(element.node().children.length === 0) {
// Check if field selector is present
var text = element.text();
var match = text.match(FIELD_SELECTOR_REG_EX);
if(match) {
// Add renderer
var textRenderer = new TextRenderer(element, TemplateParser.createDataFunction(match[1]));
templateNode.addRenderer(textRenderer);
// Remove field selector from text node
element.text(null);
}
}
};