@john-guerra/fisheye-nav 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/fisheye-nav.esm.js +4477 -0
- package/dist/fisheye-nav.js +4505 -0
- package/dist/fisheye-nav.min.js +1 -0
- package/dist/react.esm.js +4548 -0
- package/package.json +98 -0
- package/src/fisheyeNav.js +305 -0
- package/src/hierarchy.js +259 -0
- package/src/index.js +18 -0
- package/src/layout/bands.js +135 -0
- package/src/layout/index.js +202 -0
- package/src/layout/position.js +142 -0
- package/src/layout/scales.js +41 -0
- package/src/layout/select.js +256 -0
- package/src/react/index.js +81 -0
- package/src/render/controls.js +282 -0
- package/src/render/index.js +215 -0
- package/src/render/style.js +135 -0
- package/src/svelte/FisheyeNav.svelte +72 -0
|
@@ -0,0 +1,4505 @@
|
|
|
1
|
+
// @john-guerra/fisheye-nav v0.1.0 Copyright (c) 2026 John Alexis Guerra Gómez
|
|
2
|
+
(function (global, factory) {
|
|
3
|
+
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
|
4
|
+
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
|
5
|
+
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.fisheyeNav = {}));
|
|
6
|
+
})(this, (function (exports) { 'use strict';
|
|
7
|
+
|
|
8
|
+
// reactive-widget-helper v0.0.2 Copyright (c) 2024 John Alexis Guerra Gómez
|
|
9
|
+
function ReactiveWidget(
|
|
10
|
+
target,
|
|
11
|
+
{ showValue = () => {}, value } = {}
|
|
12
|
+
) {
|
|
13
|
+
// 🧰 The interval value selected by the user interaction
|
|
14
|
+
let intervalValue = value;
|
|
15
|
+
|
|
16
|
+
// 🧰 And a function to update the current internal value. This one triggers the input event
|
|
17
|
+
function setValue(newValue) {
|
|
18
|
+
intervalValue = newValue;
|
|
19
|
+
target.dispatchEvent(new CustomEvent("input", { bubbles: true }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 🧰 The value attribute setter and getter
|
|
23
|
+
Object.defineProperty(target, "value", {
|
|
24
|
+
get() {
|
|
25
|
+
return intervalValue;
|
|
26
|
+
},
|
|
27
|
+
set(newValue) {
|
|
28
|
+
intervalValue = newValue;
|
|
29
|
+
showValue();
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 🧰 Listen to the input event, and reflec the current value
|
|
34
|
+
target.addEventListener("input", (evt) => {
|
|
35
|
+
evt.preventDefault();
|
|
36
|
+
evt.stopPropagation();
|
|
37
|
+
showValue(evt);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Expose the setValue
|
|
41
|
+
target.setValue = setValue;
|
|
42
|
+
|
|
43
|
+
// 🧰 Finally return the html element
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var xhtml = "http://www.w3.org/1999/xhtml";
|
|
48
|
+
|
|
49
|
+
var namespaces = {
|
|
50
|
+
svg: "http://www.w3.org/2000/svg",
|
|
51
|
+
xhtml: xhtml,
|
|
52
|
+
xlink: "http://www.w3.org/1999/xlink",
|
|
53
|
+
xml: "http://www.w3.org/XML/1998/namespace",
|
|
54
|
+
xmlns: "http://www.w3.org/2000/xmlns/"
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function namespace(name) {
|
|
58
|
+
var prefix = name += "", i = prefix.indexOf(":");
|
|
59
|
+
if (i >= 0 && (prefix = name.slice(0, i)) !== "xmlns") name = name.slice(i + 1);
|
|
60
|
+
return namespaces.hasOwnProperty(prefix) ? {space: namespaces[prefix], local: name} : name; // eslint-disable-line no-prototype-builtins
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function creatorInherit(name) {
|
|
64
|
+
return function() {
|
|
65
|
+
var document = this.ownerDocument,
|
|
66
|
+
uri = this.namespaceURI;
|
|
67
|
+
return uri === xhtml && document.documentElement.namespaceURI === xhtml
|
|
68
|
+
? document.createElement(name)
|
|
69
|
+
: document.createElementNS(uri, name);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function creatorFixed(fullname) {
|
|
74
|
+
return function() {
|
|
75
|
+
return this.ownerDocument.createElementNS(fullname.space, fullname.local);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function creator(name) {
|
|
80
|
+
var fullname = namespace(name);
|
|
81
|
+
return (fullname.local
|
|
82
|
+
? creatorFixed
|
|
83
|
+
: creatorInherit)(fullname);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function none() {}
|
|
87
|
+
|
|
88
|
+
function selector(selector) {
|
|
89
|
+
return selector == null ? none : function() {
|
|
90
|
+
return this.querySelector(selector);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function selection_select(select) {
|
|
95
|
+
if (typeof select !== "function") select = selector(select);
|
|
96
|
+
|
|
97
|
+
for (var groups = this._groups, m = groups.length, subgroups = new Array(m), j = 0; j < m; ++j) {
|
|
98
|
+
for (var group = groups[j], n = group.length, subgroup = subgroups[j] = new Array(n), node, subnode, i = 0; i < n; ++i) {
|
|
99
|
+
if ((node = group[i]) && (subnode = select.call(node, node.__data__, i, group))) {
|
|
100
|
+
if ("__data__" in node) subnode.__data__ = node.__data__;
|
|
101
|
+
subgroup[i] = subnode;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return new Selection(subgroups, this._parents);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Given something array like (or null), returns something that is strictly an
|
|
110
|
+
// array. This is used to ensure that array-like objects passed to d3.selectAll
|
|
111
|
+
// or selection.selectAll are converted into proper arrays when creating a
|
|
112
|
+
// selection; we don’t ever want to create a selection backed by a live
|
|
113
|
+
// HTMLCollection or NodeList. However, note that selection.selectAll will use a
|
|
114
|
+
// static NodeList as a group, since it safely derived from querySelectorAll.
|
|
115
|
+
function array(x) {
|
|
116
|
+
return x == null ? [] : Array.isArray(x) ? x : Array.from(x);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function empty() {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function selectorAll(selector) {
|
|
124
|
+
return selector == null ? empty : function() {
|
|
125
|
+
return this.querySelectorAll(selector);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function arrayAll(select) {
|
|
130
|
+
return function() {
|
|
131
|
+
return array(select.apply(this, arguments));
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function selection_selectAll(select) {
|
|
136
|
+
if (typeof select === "function") select = arrayAll(select);
|
|
137
|
+
else select = selectorAll(select);
|
|
138
|
+
|
|
139
|
+
for (var groups = this._groups, m = groups.length, subgroups = [], parents = [], j = 0; j < m; ++j) {
|
|
140
|
+
for (var group = groups[j], n = group.length, node, i = 0; i < n; ++i) {
|
|
141
|
+
if (node = group[i]) {
|
|
142
|
+
subgroups.push(select.call(node, node.__data__, i, group));
|
|
143
|
+
parents.push(node);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return new Selection(subgroups, parents);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function matcher(selector) {
|
|
152
|
+
return function() {
|
|
153
|
+
return this.matches(selector);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function childMatcher(selector) {
|
|
158
|
+
return function(node) {
|
|
159
|
+
return node.matches(selector);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
var find = Array.prototype.find;
|
|
164
|
+
|
|
165
|
+
function childFind(match) {
|
|
166
|
+
return function() {
|
|
167
|
+
return find.call(this.children, match);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function childFirst() {
|
|
172
|
+
return this.firstElementChild;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function selection_selectChild(match) {
|
|
176
|
+
return this.select(match == null ? childFirst
|
|
177
|
+
: childFind(typeof match === "function" ? match : childMatcher(match)));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var filter = Array.prototype.filter;
|
|
181
|
+
|
|
182
|
+
function children() {
|
|
183
|
+
return Array.from(this.children);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function childrenFilter(match) {
|
|
187
|
+
return function() {
|
|
188
|
+
return filter.call(this.children, match);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function selection_selectChildren(match) {
|
|
193
|
+
return this.selectAll(match == null ? children
|
|
194
|
+
: childrenFilter(typeof match === "function" ? match : childMatcher(match)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function selection_filter(match) {
|
|
198
|
+
if (typeof match !== "function") match = matcher(match);
|
|
199
|
+
|
|
200
|
+
for (var groups = this._groups, m = groups.length, subgroups = new Array(m), j = 0; j < m; ++j) {
|
|
201
|
+
for (var group = groups[j], n = group.length, subgroup = subgroups[j] = [], node, i = 0; i < n; ++i) {
|
|
202
|
+
if ((node = group[i]) && match.call(node, node.__data__, i, group)) {
|
|
203
|
+
subgroup.push(node);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return new Selection(subgroups, this._parents);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sparse(update) {
|
|
212
|
+
return new Array(update.length);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function selection_enter() {
|
|
216
|
+
return new Selection(this._enter || this._groups.map(sparse), this._parents);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function EnterNode(parent, datum) {
|
|
220
|
+
this.ownerDocument = parent.ownerDocument;
|
|
221
|
+
this.namespaceURI = parent.namespaceURI;
|
|
222
|
+
this._next = null;
|
|
223
|
+
this._parent = parent;
|
|
224
|
+
this.__data__ = datum;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
EnterNode.prototype = {
|
|
228
|
+
constructor: EnterNode,
|
|
229
|
+
appendChild: function(child) { return this._parent.insertBefore(child, this._next); },
|
|
230
|
+
insertBefore: function(child, next) { return this._parent.insertBefore(child, next); },
|
|
231
|
+
querySelector: function(selector) { return this._parent.querySelector(selector); },
|
|
232
|
+
querySelectorAll: function(selector) { return this._parent.querySelectorAll(selector); }
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
function constant$1(x) {
|
|
236
|
+
return function() {
|
|
237
|
+
return x;
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function bindIndex(parent, group, enter, update, exit, data) {
|
|
242
|
+
var i = 0,
|
|
243
|
+
node,
|
|
244
|
+
groupLength = group.length,
|
|
245
|
+
dataLength = data.length;
|
|
246
|
+
|
|
247
|
+
// Put any non-null nodes that fit into update.
|
|
248
|
+
// Put any null nodes into enter.
|
|
249
|
+
// Put any remaining data into enter.
|
|
250
|
+
for (; i < dataLength; ++i) {
|
|
251
|
+
if (node = group[i]) {
|
|
252
|
+
node.__data__ = data[i];
|
|
253
|
+
update[i] = node;
|
|
254
|
+
} else {
|
|
255
|
+
enter[i] = new EnterNode(parent, data[i]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Put any non-null nodes that don’t fit into exit.
|
|
260
|
+
for (; i < groupLength; ++i) {
|
|
261
|
+
if (node = group[i]) {
|
|
262
|
+
exit[i] = node;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function bindKey(parent, group, enter, update, exit, data, key) {
|
|
268
|
+
var i,
|
|
269
|
+
node,
|
|
270
|
+
nodeByKeyValue = new Map,
|
|
271
|
+
groupLength = group.length,
|
|
272
|
+
dataLength = data.length,
|
|
273
|
+
keyValues = new Array(groupLength),
|
|
274
|
+
keyValue;
|
|
275
|
+
|
|
276
|
+
// Compute the key for each node.
|
|
277
|
+
// If multiple nodes have the same key, the duplicates are added to exit.
|
|
278
|
+
for (i = 0; i < groupLength; ++i) {
|
|
279
|
+
if (node = group[i]) {
|
|
280
|
+
keyValues[i] = keyValue = key.call(node, node.__data__, i, group) + "";
|
|
281
|
+
if (nodeByKeyValue.has(keyValue)) {
|
|
282
|
+
exit[i] = node;
|
|
283
|
+
} else {
|
|
284
|
+
nodeByKeyValue.set(keyValue, node);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Compute the key for each datum.
|
|
290
|
+
// If there a node associated with this key, join and add it to update.
|
|
291
|
+
// If there is not (or the key is a duplicate), add it to enter.
|
|
292
|
+
for (i = 0; i < dataLength; ++i) {
|
|
293
|
+
keyValue = key.call(parent, data[i], i, data) + "";
|
|
294
|
+
if (node = nodeByKeyValue.get(keyValue)) {
|
|
295
|
+
update[i] = node;
|
|
296
|
+
node.__data__ = data[i];
|
|
297
|
+
nodeByKeyValue.delete(keyValue);
|
|
298
|
+
} else {
|
|
299
|
+
enter[i] = new EnterNode(parent, data[i]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Add any remaining nodes that were not bound to data to exit.
|
|
304
|
+
for (i = 0; i < groupLength; ++i) {
|
|
305
|
+
if ((node = group[i]) && (nodeByKeyValue.get(keyValues[i]) === node)) {
|
|
306
|
+
exit[i] = node;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function datum(node) {
|
|
312
|
+
return node.__data__;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function selection_data(value, key) {
|
|
316
|
+
if (!arguments.length) return Array.from(this, datum);
|
|
317
|
+
|
|
318
|
+
var bind = key ? bindKey : bindIndex,
|
|
319
|
+
parents = this._parents,
|
|
320
|
+
groups = this._groups;
|
|
321
|
+
|
|
322
|
+
if (typeof value !== "function") value = constant$1(value);
|
|
323
|
+
|
|
324
|
+
for (var m = groups.length, update = new Array(m), enter = new Array(m), exit = new Array(m), j = 0; j < m; ++j) {
|
|
325
|
+
var parent = parents[j],
|
|
326
|
+
group = groups[j],
|
|
327
|
+
groupLength = group.length,
|
|
328
|
+
data = arraylike(value.call(parent, parent && parent.__data__, j, parents)),
|
|
329
|
+
dataLength = data.length,
|
|
330
|
+
enterGroup = enter[j] = new Array(dataLength),
|
|
331
|
+
updateGroup = update[j] = new Array(dataLength),
|
|
332
|
+
exitGroup = exit[j] = new Array(groupLength);
|
|
333
|
+
|
|
334
|
+
bind(parent, group, enterGroup, updateGroup, exitGroup, data, key);
|
|
335
|
+
|
|
336
|
+
// Now connect the enter nodes to their following update node, such that
|
|
337
|
+
// appendChild can insert the materialized enter node before this node,
|
|
338
|
+
// rather than at the end of the parent node.
|
|
339
|
+
for (var i0 = 0, i1 = 0, previous, next; i0 < dataLength; ++i0) {
|
|
340
|
+
if (previous = enterGroup[i0]) {
|
|
341
|
+
if (i0 >= i1) i1 = i0 + 1;
|
|
342
|
+
while (!(next = updateGroup[i1]) && ++i1 < dataLength);
|
|
343
|
+
previous._next = next || null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
update = new Selection(update, parents);
|
|
349
|
+
update._enter = enter;
|
|
350
|
+
update._exit = exit;
|
|
351
|
+
return update;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Given some data, this returns an array-like view of it: an object that
|
|
355
|
+
// exposes a length property and allows numeric indexing. Note that unlike
|
|
356
|
+
// selectAll, this isn’t worried about “live” collections because the resulting
|
|
357
|
+
// array will only be used briefly while data is being bound. (It is possible to
|
|
358
|
+
// cause the data to change while iterating by using a key function, but please
|
|
359
|
+
// don’t; we’d rather avoid a gratuitous copy.)
|
|
360
|
+
function arraylike(data) {
|
|
361
|
+
return typeof data === "object" && "length" in data
|
|
362
|
+
? data // Array, TypedArray, NodeList, array-like
|
|
363
|
+
: Array.from(data); // Map, Set, iterable, string, or anything else
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function selection_exit() {
|
|
367
|
+
return new Selection(this._exit || this._groups.map(sparse), this._parents);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function selection_join(onenter, onupdate, onexit) {
|
|
371
|
+
var enter = this.enter(), update = this, exit = this.exit();
|
|
372
|
+
if (typeof onenter === "function") {
|
|
373
|
+
enter = onenter(enter);
|
|
374
|
+
if (enter) enter = enter.selection();
|
|
375
|
+
} else {
|
|
376
|
+
enter = enter.append(onenter + "");
|
|
377
|
+
}
|
|
378
|
+
if (onupdate != null) {
|
|
379
|
+
update = onupdate(update);
|
|
380
|
+
if (update) update = update.selection();
|
|
381
|
+
}
|
|
382
|
+
if (onexit == null) exit.remove(); else onexit(exit);
|
|
383
|
+
return enter && update ? enter.merge(update).order() : update;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function selection_merge(context) {
|
|
387
|
+
var selection = context.selection ? context.selection() : context;
|
|
388
|
+
|
|
389
|
+
for (var groups0 = this._groups, groups1 = selection._groups, m0 = groups0.length, m1 = groups1.length, m = Math.min(m0, m1), merges = new Array(m0), j = 0; j < m; ++j) {
|
|
390
|
+
for (var group0 = groups0[j], group1 = groups1[j], n = group0.length, merge = merges[j] = new Array(n), node, i = 0; i < n; ++i) {
|
|
391
|
+
if (node = group0[i] || group1[i]) {
|
|
392
|
+
merge[i] = node;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (; j < m0; ++j) {
|
|
398
|
+
merges[j] = groups0[j];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return new Selection(merges, this._parents);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function selection_order() {
|
|
405
|
+
|
|
406
|
+
for (var groups = this._groups, j = -1, m = groups.length; ++j < m;) {
|
|
407
|
+
for (var group = groups[j], i = group.length - 1, next = group[i], node; --i >= 0;) {
|
|
408
|
+
if (node = group[i]) {
|
|
409
|
+
if (next && node.compareDocumentPosition(next) ^ 4) next.parentNode.insertBefore(node, next);
|
|
410
|
+
next = node;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return this;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function selection_sort(compare) {
|
|
419
|
+
if (!compare) compare = ascending$1;
|
|
420
|
+
|
|
421
|
+
function compareNode(a, b) {
|
|
422
|
+
return a && b ? compare(a.__data__, b.__data__) : !a - !b;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (var groups = this._groups, m = groups.length, sortgroups = new Array(m), j = 0; j < m; ++j) {
|
|
426
|
+
for (var group = groups[j], n = group.length, sortgroup = sortgroups[j] = new Array(n), node, i = 0; i < n; ++i) {
|
|
427
|
+
if (node = group[i]) {
|
|
428
|
+
sortgroup[i] = node;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
sortgroup.sort(compareNode);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return new Selection(sortgroups, this._parents).order();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function ascending$1(a, b) {
|
|
438
|
+
return a < b ? -1 : a > b ? 1 : a >= b ? 0 : NaN;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function selection_call() {
|
|
442
|
+
var callback = arguments[0];
|
|
443
|
+
arguments[0] = this;
|
|
444
|
+
callback.apply(null, arguments);
|
|
445
|
+
return this;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function selection_nodes() {
|
|
449
|
+
return Array.from(this);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function selection_node() {
|
|
453
|
+
|
|
454
|
+
for (var groups = this._groups, j = 0, m = groups.length; j < m; ++j) {
|
|
455
|
+
for (var group = groups[j], i = 0, n = group.length; i < n; ++i) {
|
|
456
|
+
var node = group[i];
|
|
457
|
+
if (node) return node;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function selection_size() {
|
|
465
|
+
let size = 0;
|
|
466
|
+
for (const node of this) ++size; // eslint-disable-line no-unused-vars
|
|
467
|
+
return size;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function selection_empty() {
|
|
471
|
+
return !this.node();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function selection_each(callback) {
|
|
475
|
+
|
|
476
|
+
for (var groups = this._groups, j = 0, m = groups.length; j < m; ++j) {
|
|
477
|
+
for (var group = groups[j], i = 0, n = group.length, node; i < n; ++i) {
|
|
478
|
+
if (node = group[i]) callback.call(node, node.__data__, i, group);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return this;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function attrRemove(name) {
|
|
486
|
+
return function() {
|
|
487
|
+
this.removeAttribute(name);
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function attrRemoveNS(fullname) {
|
|
492
|
+
return function() {
|
|
493
|
+
this.removeAttributeNS(fullname.space, fullname.local);
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function attrConstant(name, value) {
|
|
498
|
+
return function() {
|
|
499
|
+
this.setAttribute(name, value);
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function attrConstantNS(fullname, value) {
|
|
504
|
+
return function() {
|
|
505
|
+
this.setAttributeNS(fullname.space, fullname.local, value);
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function attrFunction(name, value) {
|
|
510
|
+
return function() {
|
|
511
|
+
var v = value.apply(this, arguments);
|
|
512
|
+
if (v == null) this.removeAttribute(name);
|
|
513
|
+
else this.setAttribute(name, v);
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function attrFunctionNS(fullname, value) {
|
|
518
|
+
return function() {
|
|
519
|
+
var v = value.apply(this, arguments);
|
|
520
|
+
if (v == null) this.removeAttributeNS(fullname.space, fullname.local);
|
|
521
|
+
else this.setAttributeNS(fullname.space, fullname.local, v);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function selection_attr(name, value) {
|
|
526
|
+
var fullname = namespace(name);
|
|
527
|
+
|
|
528
|
+
if (arguments.length < 2) {
|
|
529
|
+
var node = this.node();
|
|
530
|
+
return fullname.local
|
|
531
|
+
? node.getAttributeNS(fullname.space, fullname.local)
|
|
532
|
+
: node.getAttribute(fullname);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return this.each((value == null
|
|
536
|
+
? (fullname.local ? attrRemoveNS : attrRemove) : (typeof value === "function"
|
|
537
|
+
? (fullname.local ? attrFunctionNS : attrFunction)
|
|
538
|
+
: (fullname.local ? attrConstantNS : attrConstant)))(fullname, value));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function defaultView(node) {
|
|
542
|
+
return (node.ownerDocument && node.ownerDocument.defaultView) // node is a Node
|
|
543
|
+
|| (node.document && node) // node is a Window
|
|
544
|
+
|| node.defaultView; // node is a Document
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function styleRemove(name) {
|
|
548
|
+
return function() {
|
|
549
|
+
this.style.removeProperty(name);
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function styleConstant(name, value, priority) {
|
|
554
|
+
return function() {
|
|
555
|
+
this.style.setProperty(name, value, priority);
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function styleFunction(name, value, priority) {
|
|
560
|
+
return function() {
|
|
561
|
+
var v = value.apply(this, arguments);
|
|
562
|
+
if (v == null) this.style.removeProperty(name);
|
|
563
|
+
else this.style.setProperty(name, v, priority);
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function selection_style(name, value, priority) {
|
|
568
|
+
return arguments.length > 1
|
|
569
|
+
? this.each((value == null
|
|
570
|
+
? styleRemove : typeof value === "function"
|
|
571
|
+
? styleFunction
|
|
572
|
+
: styleConstant)(name, value, priority == null ? "" : priority))
|
|
573
|
+
: styleValue(this.node(), name);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function styleValue(node, name) {
|
|
577
|
+
return node.style.getPropertyValue(name)
|
|
578
|
+
|| defaultView(node).getComputedStyle(node, null).getPropertyValue(name);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function propertyRemove(name) {
|
|
582
|
+
return function() {
|
|
583
|
+
delete this[name];
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function propertyConstant(name, value) {
|
|
588
|
+
return function() {
|
|
589
|
+
this[name] = value;
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function propertyFunction(name, value) {
|
|
594
|
+
return function() {
|
|
595
|
+
var v = value.apply(this, arguments);
|
|
596
|
+
if (v == null) delete this[name];
|
|
597
|
+
else this[name] = v;
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function selection_property(name, value) {
|
|
602
|
+
return arguments.length > 1
|
|
603
|
+
? this.each((value == null
|
|
604
|
+
? propertyRemove : typeof value === "function"
|
|
605
|
+
? propertyFunction
|
|
606
|
+
: propertyConstant)(name, value))
|
|
607
|
+
: this.node()[name];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function classArray(string) {
|
|
611
|
+
return string.trim().split(/^|\s+/);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function classList(node) {
|
|
615
|
+
return node.classList || new ClassList(node);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function ClassList(node) {
|
|
619
|
+
this._node = node;
|
|
620
|
+
this._names = classArray(node.getAttribute("class") || "");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
ClassList.prototype = {
|
|
624
|
+
add: function(name) {
|
|
625
|
+
var i = this._names.indexOf(name);
|
|
626
|
+
if (i < 0) {
|
|
627
|
+
this._names.push(name);
|
|
628
|
+
this._node.setAttribute("class", this._names.join(" "));
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
remove: function(name) {
|
|
632
|
+
var i = this._names.indexOf(name);
|
|
633
|
+
if (i >= 0) {
|
|
634
|
+
this._names.splice(i, 1);
|
|
635
|
+
this._node.setAttribute("class", this._names.join(" "));
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
contains: function(name) {
|
|
639
|
+
return this._names.indexOf(name) >= 0;
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
function classedAdd(node, names) {
|
|
644
|
+
var list = classList(node), i = -1, n = names.length;
|
|
645
|
+
while (++i < n) list.add(names[i]);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function classedRemove(node, names) {
|
|
649
|
+
var list = classList(node), i = -1, n = names.length;
|
|
650
|
+
while (++i < n) list.remove(names[i]);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function classedTrue(names) {
|
|
654
|
+
return function() {
|
|
655
|
+
classedAdd(this, names);
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function classedFalse(names) {
|
|
660
|
+
return function() {
|
|
661
|
+
classedRemove(this, names);
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function classedFunction(names, value) {
|
|
666
|
+
return function() {
|
|
667
|
+
(value.apply(this, arguments) ? classedAdd : classedRemove)(this, names);
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function selection_classed(name, value) {
|
|
672
|
+
var names = classArray(name + "");
|
|
673
|
+
|
|
674
|
+
if (arguments.length < 2) {
|
|
675
|
+
var list = classList(this.node()), i = -1, n = names.length;
|
|
676
|
+
while (++i < n) if (!list.contains(names[i])) return false;
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return this.each((typeof value === "function"
|
|
681
|
+
? classedFunction : value
|
|
682
|
+
? classedTrue
|
|
683
|
+
: classedFalse)(names, value));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function textRemove() {
|
|
687
|
+
this.textContent = "";
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function textConstant(value) {
|
|
691
|
+
return function() {
|
|
692
|
+
this.textContent = value;
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function textFunction(value) {
|
|
697
|
+
return function() {
|
|
698
|
+
var v = value.apply(this, arguments);
|
|
699
|
+
this.textContent = v == null ? "" : v;
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function selection_text(value) {
|
|
704
|
+
return arguments.length
|
|
705
|
+
? this.each(value == null
|
|
706
|
+
? textRemove : (typeof value === "function"
|
|
707
|
+
? textFunction
|
|
708
|
+
: textConstant)(value))
|
|
709
|
+
: this.node().textContent;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function htmlRemove() {
|
|
713
|
+
this.innerHTML = "";
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function htmlConstant(value) {
|
|
717
|
+
return function() {
|
|
718
|
+
this.innerHTML = value;
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function htmlFunction(value) {
|
|
723
|
+
return function() {
|
|
724
|
+
var v = value.apply(this, arguments);
|
|
725
|
+
this.innerHTML = v == null ? "" : v;
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function selection_html(value) {
|
|
730
|
+
return arguments.length
|
|
731
|
+
? this.each(value == null
|
|
732
|
+
? htmlRemove : (typeof value === "function"
|
|
733
|
+
? htmlFunction
|
|
734
|
+
: htmlConstant)(value))
|
|
735
|
+
: this.node().innerHTML;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function raise() {
|
|
739
|
+
if (this.nextSibling) this.parentNode.appendChild(this);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function selection_raise() {
|
|
743
|
+
return this.each(raise);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function lower() {
|
|
747
|
+
if (this.previousSibling) this.parentNode.insertBefore(this, this.parentNode.firstChild);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function selection_lower() {
|
|
751
|
+
return this.each(lower);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function selection_append(name) {
|
|
755
|
+
var create = typeof name === "function" ? name : creator(name);
|
|
756
|
+
return this.select(function() {
|
|
757
|
+
return this.appendChild(create.apply(this, arguments));
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function constantNull() {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function selection_insert(name, before) {
|
|
766
|
+
var create = typeof name === "function" ? name : creator(name),
|
|
767
|
+
select = before == null ? constantNull : typeof before === "function" ? before : selector(before);
|
|
768
|
+
return this.select(function() {
|
|
769
|
+
return this.insertBefore(create.apply(this, arguments), select.apply(this, arguments) || null);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function remove() {
|
|
774
|
+
var parent = this.parentNode;
|
|
775
|
+
if (parent) parent.removeChild(this);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function selection_remove() {
|
|
779
|
+
return this.each(remove);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function selection_cloneShallow() {
|
|
783
|
+
var clone = this.cloneNode(false), parent = this.parentNode;
|
|
784
|
+
return parent ? parent.insertBefore(clone, this.nextSibling) : clone;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function selection_cloneDeep() {
|
|
788
|
+
var clone = this.cloneNode(true), parent = this.parentNode;
|
|
789
|
+
return parent ? parent.insertBefore(clone, this.nextSibling) : clone;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function selection_clone(deep) {
|
|
793
|
+
return this.select(deep ? selection_cloneDeep : selection_cloneShallow);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function selection_datum(value) {
|
|
797
|
+
return arguments.length
|
|
798
|
+
? this.property("__data__", value)
|
|
799
|
+
: this.node().__data__;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function contextListener(listener) {
|
|
803
|
+
return function(event) {
|
|
804
|
+
listener.call(this, event, this.__data__);
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function parseTypenames(typenames) {
|
|
809
|
+
return typenames.trim().split(/^|\s+/).map(function(t) {
|
|
810
|
+
var name = "", i = t.indexOf(".");
|
|
811
|
+
if (i >= 0) name = t.slice(i + 1), t = t.slice(0, i);
|
|
812
|
+
return {type: t, name: name};
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function onRemove(typename) {
|
|
817
|
+
return function() {
|
|
818
|
+
var on = this.__on;
|
|
819
|
+
if (!on) return;
|
|
820
|
+
for (var j = 0, i = -1, m = on.length, o; j < m; ++j) {
|
|
821
|
+
if (o = on[j], (!typename.type || o.type === typename.type) && o.name === typename.name) {
|
|
822
|
+
this.removeEventListener(o.type, o.listener, o.options);
|
|
823
|
+
} else {
|
|
824
|
+
on[++i] = o;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (++i) on.length = i;
|
|
828
|
+
else delete this.__on;
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function onAdd(typename, value, options) {
|
|
833
|
+
return function() {
|
|
834
|
+
var on = this.__on, o, listener = contextListener(value);
|
|
835
|
+
if (on) for (var j = 0, m = on.length; j < m; ++j) {
|
|
836
|
+
if ((o = on[j]).type === typename.type && o.name === typename.name) {
|
|
837
|
+
this.removeEventListener(o.type, o.listener, o.options);
|
|
838
|
+
this.addEventListener(o.type, o.listener = listener, o.options = options);
|
|
839
|
+
o.value = value;
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
this.addEventListener(typename.type, listener, options);
|
|
844
|
+
o = {type: typename.type, name: typename.name, value: value, listener: listener, options: options};
|
|
845
|
+
if (!on) this.__on = [o];
|
|
846
|
+
else on.push(o);
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function selection_on(typename, value, options) {
|
|
851
|
+
var typenames = parseTypenames(typename + ""), i, n = typenames.length, t;
|
|
852
|
+
|
|
853
|
+
if (arguments.length < 2) {
|
|
854
|
+
var on = this.node().__on;
|
|
855
|
+
if (on) for (var j = 0, m = on.length, o; j < m; ++j) {
|
|
856
|
+
for (i = 0, o = on[j]; i < n; ++i) {
|
|
857
|
+
if ((t = typenames[i]).type === o.type && t.name === o.name) {
|
|
858
|
+
return o.value;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
on = value ? onAdd : onRemove;
|
|
866
|
+
for (i = 0; i < n; ++i) this.each(on(typenames[i], value, options));
|
|
867
|
+
return this;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function dispatchEvent(node, type, params) {
|
|
871
|
+
var window = defaultView(node),
|
|
872
|
+
event = window.CustomEvent;
|
|
873
|
+
|
|
874
|
+
if (typeof event === "function") {
|
|
875
|
+
event = new event(type, params);
|
|
876
|
+
} else {
|
|
877
|
+
event = window.document.createEvent("Event");
|
|
878
|
+
if (params) event.initEvent(type, params.bubbles, params.cancelable), event.detail = params.detail;
|
|
879
|
+
else event.initEvent(type, false, false);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
node.dispatchEvent(event);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function dispatchConstant(type, params) {
|
|
886
|
+
return function() {
|
|
887
|
+
return dispatchEvent(this, type, params);
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function dispatchFunction(type, params) {
|
|
892
|
+
return function() {
|
|
893
|
+
return dispatchEvent(this, type, params.apply(this, arguments));
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function selection_dispatch(type, params) {
|
|
898
|
+
return this.each((typeof params === "function"
|
|
899
|
+
? dispatchFunction
|
|
900
|
+
: dispatchConstant)(type, params));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function* selection_iterator() {
|
|
904
|
+
for (var groups = this._groups, j = 0, m = groups.length; j < m; ++j) {
|
|
905
|
+
for (var group = groups[j], i = 0, n = group.length, node; i < n; ++i) {
|
|
906
|
+
if (node = group[i]) yield node;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
var root = [null];
|
|
912
|
+
|
|
913
|
+
function Selection(groups, parents) {
|
|
914
|
+
this._groups = groups;
|
|
915
|
+
this._parents = parents;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function selection_selection() {
|
|
919
|
+
return this;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
Selection.prototype = {
|
|
923
|
+
constructor: Selection,
|
|
924
|
+
select: selection_select,
|
|
925
|
+
selectAll: selection_selectAll,
|
|
926
|
+
selectChild: selection_selectChild,
|
|
927
|
+
selectChildren: selection_selectChildren,
|
|
928
|
+
filter: selection_filter,
|
|
929
|
+
data: selection_data,
|
|
930
|
+
enter: selection_enter,
|
|
931
|
+
exit: selection_exit,
|
|
932
|
+
join: selection_join,
|
|
933
|
+
merge: selection_merge,
|
|
934
|
+
selection: selection_selection,
|
|
935
|
+
order: selection_order,
|
|
936
|
+
sort: selection_sort,
|
|
937
|
+
call: selection_call,
|
|
938
|
+
nodes: selection_nodes,
|
|
939
|
+
node: selection_node,
|
|
940
|
+
size: selection_size,
|
|
941
|
+
empty: selection_empty,
|
|
942
|
+
each: selection_each,
|
|
943
|
+
attr: selection_attr,
|
|
944
|
+
style: selection_style,
|
|
945
|
+
property: selection_property,
|
|
946
|
+
classed: selection_classed,
|
|
947
|
+
text: selection_text,
|
|
948
|
+
html: selection_html,
|
|
949
|
+
raise: selection_raise,
|
|
950
|
+
lower: selection_lower,
|
|
951
|
+
append: selection_append,
|
|
952
|
+
insert: selection_insert,
|
|
953
|
+
remove: selection_remove,
|
|
954
|
+
clone: selection_clone,
|
|
955
|
+
datum: selection_datum,
|
|
956
|
+
on: selection_on,
|
|
957
|
+
dispatch: selection_dispatch,
|
|
958
|
+
[Symbol.iterator]: selection_iterator
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
function select(selector) {
|
|
962
|
+
return typeof selector === "string"
|
|
963
|
+
? new Selection([[document.querySelector(selector)]], [document.documentElement])
|
|
964
|
+
: new Selection([[selector]], root);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function sourceEvent(event) {
|
|
968
|
+
let sourceEvent;
|
|
969
|
+
while (sourceEvent = event.sourceEvent) event = sourceEvent;
|
|
970
|
+
return event;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function pointer(event, node) {
|
|
974
|
+
event = sourceEvent(event);
|
|
975
|
+
if (node === undefined) node = event.currentTarget;
|
|
976
|
+
if (node) {
|
|
977
|
+
var svg = node.ownerSVGElement || node;
|
|
978
|
+
if (svg.createSVGPoint) {
|
|
979
|
+
var point = svg.createSVGPoint();
|
|
980
|
+
point.x = event.clientX, point.y = event.clientY;
|
|
981
|
+
point = point.matrixTransform(node.getScreenCTM().inverse());
|
|
982
|
+
return [point.x, point.y];
|
|
983
|
+
}
|
|
984
|
+
if (node.getBoundingClientRect) {
|
|
985
|
+
var rect = node.getBoundingClientRect();
|
|
986
|
+
return [event.clientX - rect.left - node.clientLeft, event.clientY - rect.top - node.clientTop];
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return [event.pageX, event.pageY];
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function count(node) {
|
|
993
|
+
var sum = 0,
|
|
994
|
+
children = node.children,
|
|
995
|
+
i = children && children.length;
|
|
996
|
+
if (!i) sum = 1;
|
|
997
|
+
else while (--i >= 0) sum += children[i].value;
|
|
998
|
+
node.value = sum;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function node_count() {
|
|
1002
|
+
return this.eachAfter(count);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function node_each(callback, that) {
|
|
1006
|
+
let index = -1;
|
|
1007
|
+
for (const node of this) {
|
|
1008
|
+
callback.call(that, node, ++index, this);
|
|
1009
|
+
}
|
|
1010
|
+
return this;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function node_eachBefore(callback, that) {
|
|
1014
|
+
var node = this, nodes = [node], children, i, index = -1;
|
|
1015
|
+
while (node = nodes.pop()) {
|
|
1016
|
+
callback.call(that, node, ++index, this);
|
|
1017
|
+
if (children = node.children) {
|
|
1018
|
+
for (i = children.length - 1; i >= 0; --i) {
|
|
1019
|
+
nodes.push(children[i]);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return this;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function node_eachAfter(callback, that) {
|
|
1027
|
+
var node = this, nodes = [node], next = [], children, i, n, index = -1;
|
|
1028
|
+
while (node = nodes.pop()) {
|
|
1029
|
+
next.push(node);
|
|
1030
|
+
if (children = node.children) {
|
|
1031
|
+
for (i = 0, n = children.length; i < n; ++i) {
|
|
1032
|
+
nodes.push(children[i]);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
while (node = next.pop()) {
|
|
1037
|
+
callback.call(that, node, ++index, this);
|
|
1038
|
+
}
|
|
1039
|
+
return this;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function node_find(callback, that) {
|
|
1043
|
+
let index = -1;
|
|
1044
|
+
for (const node of this) {
|
|
1045
|
+
if (callback.call(that, node, ++index, this)) {
|
|
1046
|
+
return node;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function node_sum(value) {
|
|
1052
|
+
return this.eachAfter(function(node) {
|
|
1053
|
+
var sum = +value(node.data) || 0,
|
|
1054
|
+
children = node.children,
|
|
1055
|
+
i = children && children.length;
|
|
1056
|
+
while (--i >= 0) sum += children[i].value;
|
|
1057
|
+
node.value = sum;
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function node_sort(compare) {
|
|
1062
|
+
return this.eachBefore(function(node) {
|
|
1063
|
+
if (node.children) {
|
|
1064
|
+
node.children.sort(compare);
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function node_path(end) {
|
|
1070
|
+
var start = this,
|
|
1071
|
+
ancestor = leastCommonAncestor(start, end),
|
|
1072
|
+
nodes = [start];
|
|
1073
|
+
while (start !== ancestor) {
|
|
1074
|
+
start = start.parent;
|
|
1075
|
+
nodes.push(start);
|
|
1076
|
+
}
|
|
1077
|
+
var k = nodes.length;
|
|
1078
|
+
while (end !== ancestor) {
|
|
1079
|
+
nodes.splice(k, 0, end);
|
|
1080
|
+
end = end.parent;
|
|
1081
|
+
}
|
|
1082
|
+
return nodes;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function leastCommonAncestor(a, b) {
|
|
1086
|
+
if (a === b) return a;
|
|
1087
|
+
var aNodes = a.ancestors(),
|
|
1088
|
+
bNodes = b.ancestors(),
|
|
1089
|
+
c = null;
|
|
1090
|
+
a = aNodes.pop();
|
|
1091
|
+
b = bNodes.pop();
|
|
1092
|
+
while (a === b) {
|
|
1093
|
+
c = a;
|
|
1094
|
+
a = aNodes.pop();
|
|
1095
|
+
b = bNodes.pop();
|
|
1096
|
+
}
|
|
1097
|
+
return c;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function node_ancestors() {
|
|
1101
|
+
var node = this, nodes = [node];
|
|
1102
|
+
while (node = node.parent) {
|
|
1103
|
+
nodes.push(node);
|
|
1104
|
+
}
|
|
1105
|
+
return nodes;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function node_descendants() {
|
|
1109
|
+
return Array.from(this);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function node_leaves() {
|
|
1113
|
+
var leaves = [];
|
|
1114
|
+
this.eachBefore(function(node) {
|
|
1115
|
+
if (!node.children) {
|
|
1116
|
+
leaves.push(node);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
return leaves;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function node_links() {
|
|
1123
|
+
var root = this, links = [];
|
|
1124
|
+
root.each(function(node) {
|
|
1125
|
+
if (node !== root) { // Don’t include the root’s parent, if any.
|
|
1126
|
+
links.push({source: node.parent, target: node});
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
return links;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function* node_iterator() {
|
|
1133
|
+
var node = this, current, next = [node], children, i, n;
|
|
1134
|
+
do {
|
|
1135
|
+
current = next.reverse(), next = [];
|
|
1136
|
+
while (node = current.pop()) {
|
|
1137
|
+
yield node;
|
|
1138
|
+
if (children = node.children) {
|
|
1139
|
+
for (i = 0, n = children.length; i < n; ++i) {
|
|
1140
|
+
next.push(children[i]);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
} while (next.length);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function hierarchy(data, children) {
|
|
1148
|
+
if (data instanceof Map) {
|
|
1149
|
+
data = [undefined, data];
|
|
1150
|
+
if (children === undefined) children = mapChildren;
|
|
1151
|
+
} else if (children === undefined) {
|
|
1152
|
+
children = objectChildren;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
var root = new Node(data),
|
|
1156
|
+
node,
|
|
1157
|
+
nodes = [root],
|
|
1158
|
+
child,
|
|
1159
|
+
childs,
|
|
1160
|
+
i,
|
|
1161
|
+
n;
|
|
1162
|
+
|
|
1163
|
+
while (node = nodes.pop()) {
|
|
1164
|
+
if ((childs = children(node.data)) && (n = (childs = Array.from(childs)).length)) {
|
|
1165
|
+
node.children = childs;
|
|
1166
|
+
for (i = n - 1; i >= 0; --i) {
|
|
1167
|
+
nodes.push(child = childs[i] = new Node(childs[i]));
|
|
1168
|
+
child.parent = node;
|
|
1169
|
+
child.depth = node.depth + 1;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
return root.eachBefore(computeHeight);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function node_copy() {
|
|
1178
|
+
return hierarchy(this).eachBefore(copyData);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function objectChildren(d) {
|
|
1182
|
+
return d.children;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function mapChildren(d) {
|
|
1186
|
+
return Array.isArray(d) ? d[1] : null;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function copyData(node) {
|
|
1190
|
+
if (node.data.value !== undefined) node.value = node.data.value;
|
|
1191
|
+
node.data = node.data.data;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function computeHeight(node) {
|
|
1195
|
+
var height = 0;
|
|
1196
|
+
do node.height = height;
|
|
1197
|
+
while ((node = node.parent) && (node.height < ++height));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function Node(data) {
|
|
1201
|
+
this.data = data;
|
|
1202
|
+
this.depth =
|
|
1203
|
+
this.height = 0;
|
|
1204
|
+
this.parent = null;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
Node.prototype = hierarchy.prototype = {
|
|
1208
|
+
constructor: Node,
|
|
1209
|
+
count: node_count,
|
|
1210
|
+
each: node_each,
|
|
1211
|
+
eachAfter: node_eachAfter,
|
|
1212
|
+
eachBefore: node_eachBefore,
|
|
1213
|
+
find: node_find,
|
|
1214
|
+
sum: node_sum,
|
|
1215
|
+
sort: node_sort,
|
|
1216
|
+
path: node_path,
|
|
1217
|
+
ancestors: node_ancestors,
|
|
1218
|
+
descendants: node_descendants,
|
|
1219
|
+
leaves: node_leaves,
|
|
1220
|
+
links: node_links,
|
|
1221
|
+
copy: node_copy,
|
|
1222
|
+
[Symbol.iterator]: node_iterator
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Input normalization: both accepted input shapes collapse into ONE decorated
|
|
1227
|
+
* `d3.hierarchy`, so every layout strategy and every renderer downstream sees
|
|
1228
|
+
* exactly one data structure.
|
|
1229
|
+
*
|
|
1230
|
+
* A) flat rows + keys { data: [...], keys: ["year","month","day"] }
|
|
1231
|
+
* B) nested tree { root: {...}, children: d => d.children }
|
|
1232
|
+
*
|
|
1233
|
+
* (A) is the "tidy long table" form — a sorted list of value-tuples, which is
|
|
1234
|
+
* what a single SQL `GROUP BY` hands you. (B) is for ragged/precomputed trees.
|
|
1235
|
+
*/
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
/** Field on every node.data. `value` is the node's own key-value (e.g. "2024"),
|
|
1239
|
+
* NOT a count — counts live on `node.count`.
|
|
1240
|
+
* @typedef {{key: string|null, value: any, label: string, row: any}} NodeDatum */
|
|
1241
|
+
|
|
1242
|
+
// A separator that cannot occur inside a key or a value. Written as an ESCAPE,
|
|
1243
|
+
// never as a literal NUL byte: a raw \0 in the source makes the whole file look
|
|
1244
|
+
// like binary data, and grep silently reports no matches in it. That cost an hour.
|
|
1245
|
+
const PATH_SEP = "\u0000";
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* The id of a path — the ONE place it is spelled.
|
|
1249
|
+
*
|
|
1250
|
+
* `fisheyeNav` used to build the selected id by joining with a space while the
|
|
1251
|
+
* rows joined with PATH_SEP, so a selection deeper than one level could never
|
|
1252
|
+
* match a row: selecting a DAY never highlighted it, and never labelled it. It
|
|
1253
|
+
* hid because the only test selected a depth-1 path, where the two joins produce
|
|
1254
|
+
* the same string.
|
|
1255
|
+
*/
|
|
1256
|
+
const idOfPath = (path) =>
|
|
1257
|
+
path.map((p) => p.key + "=" + p.value).join(PATH_SEP);
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Roll a flat, pre-sorted row list up into a nested tree, preserving the row
|
|
1261
|
+
* order as the child order at every level. Order matters: the host (AutoGallery)
|
|
1262
|
+
* hands us rows already sorted the way its feed is sorted, and the navigator
|
|
1263
|
+
* must mirror that exactly — so we never re-sort, we just group.
|
|
1264
|
+
*/
|
|
1265
|
+
function rollup(rows, keys, valueOf, countOf) {
|
|
1266
|
+
const root = { key: null, value: null, children: [], row: null };
|
|
1267
|
+
const byPath = new Map([["", root]]);
|
|
1268
|
+
|
|
1269
|
+
for (const row of rows) {
|
|
1270
|
+
let node = root;
|
|
1271
|
+
let path = "";
|
|
1272
|
+
for (const key of keys) {
|
|
1273
|
+
const value = valueOf(row, key);
|
|
1274
|
+
path += PATH_SEP + key + "=" + value;
|
|
1275
|
+
let child = byPath.get(path);
|
|
1276
|
+
if (!child) {
|
|
1277
|
+
child = { key, value, children: [], row: null };
|
|
1278
|
+
byPath.set(path, child);
|
|
1279
|
+
node.children.push(child);
|
|
1280
|
+
}
|
|
1281
|
+
node = child;
|
|
1282
|
+
}
|
|
1283
|
+
// `node` is now the leaf for this row's key-tuple. Several rows can land on
|
|
1284
|
+
// the same leaf; their counts add up and the last row wins as the exemplar.
|
|
1285
|
+
node.row = row;
|
|
1286
|
+
node.ownCount = (node.ownCount ?? 0) + countOf(row);
|
|
1287
|
+
}
|
|
1288
|
+
return root;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Give every internal node a synthetic HEADER pseudo-leaf as its first child.
|
|
1293
|
+
*
|
|
1294
|
+
* This is what makes the flat (indented outline) view possible without a second
|
|
1295
|
+
* layout engine. The layout distributes pixels over the LEAF axis, and a
|
|
1296
|
+
* parent's band is exactly the span of its children — so there is no vertical
|
|
1297
|
+
* space anywhere for a parent's own label to live. In an icicle that's fine (the
|
|
1298
|
+
* parent owns a column); in a flat list it means an internal node has no row,
|
|
1299
|
+
* and with nothing to indent relative to, "indent by level" says nothing.
|
|
1300
|
+
*
|
|
1301
|
+
* A header cell costs one slot on the leaf axis and carries `count: 0`, so mass
|
|
1302
|
+
* conservation is untouched, and it sits INSIDE its parent's interval, so
|
|
1303
|
+
* containment is untouched. Every downstream stage needs zero changes.
|
|
1304
|
+
*/
|
|
1305
|
+
function withHeaders(raw, childrenOf, isRoot = true) {
|
|
1306
|
+
const kids = childrenOf(raw);
|
|
1307
|
+
if (!kids?.length) return raw;
|
|
1308
|
+
const nested = kids.map((k) => withHeaders(k, childrenOf, false));
|
|
1309
|
+
// The root is never drawn (depth 0), so a header for it would surface as a
|
|
1310
|
+
// stray depth-1 row labelled with nothing.
|
|
1311
|
+
if (isRoot) return { ...raw, children: nested };
|
|
1312
|
+
return {
|
|
1313
|
+
...raw,
|
|
1314
|
+
children: [{ __header: true, __of: raw, children: null }, ...nested],
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Depth-first, in child order. */
|
|
1319
|
+
function walk(node, visit) {
|
|
1320
|
+
visit(node);
|
|
1321
|
+
if (node.children) for (const c of node.children) walk(c, visit);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Decorate a d3.hierarchy in place with everything the layouts and renderers
|
|
1326
|
+
* need, so neither has to re-derive it:
|
|
1327
|
+
* node.count photo mass (leaf: from the data; internal: sum of children)
|
|
1328
|
+
* node.leafStart index of its first descendant leaf } the interval the
|
|
1329
|
+
* node.leafEnd index past its last descendant leaf } fisheye distorts
|
|
1330
|
+
* node.path [{key, value}, …] from the root down to this node
|
|
1331
|
+
* node.id stable string id (survives relayout; keys the DOM)
|
|
1332
|
+
* node.label display string
|
|
1333
|
+
*
|
|
1334
|
+
* `leafStart`/`leafEnd` are the load-bearing bit of the redesign: once every
|
|
1335
|
+
* node owns a LEAF INTERVAL, a parent's band is just the union of its children's
|
|
1336
|
+
* bands, and "parents contain their children" stops being something a renderer
|
|
1337
|
+
* has to be careful about and becomes true by construction.
|
|
1338
|
+
*/
|
|
1339
|
+
function decorate(root, labelOf) {
|
|
1340
|
+
const leaves = [];
|
|
1341
|
+
walk(root, (n) => {
|
|
1342
|
+
if (!n.children?.length) leaves.push(n);
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
leaves.forEach((leaf, i) => {
|
|
1346
|
+
leaf.leafStart = i;
|
|
1347
|
+
leaf.leafEnd = i + 1;
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
// Pre-order: a node's path extends its PARENT's, so parents go first.
|
|
1351
|
+
root.eachBefore((n) => {
|
|
1352
|
+
const d = n.data;
|
|
1353
|
+
// Coerce here, once. A host's `label` accessor is arbitrary user code and
|
|
1354
|
+
// may well return undefined for a key it doesn't know about (an unknown
|
|
1355
|
+
// dimension, the synthetic root's null key). The renderer must never be the
|
|
1356
|
+
// place that discovers this — a widget that throws because a consumer's
|
|
1357
|
+
// accessor came back empty is a broken widget.
|
|
1358
|
+
n.label = String(labelOf(d.value, d.key, n) ?? d.value ?? "");
|
|
1359
|
+
if (d.header) {
|
|
1360
|
+
// A header stands in for its parent: same path (so clicking it selects the
|
|
1361
|
+
// parent, which is what you mean when you click "2024"), distinct id.
|
|
1362
|
+
n.keyPath = n.parent.keyPath;
|
|
1363
|
+
n.id = n.parent.id + "#header";
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
// `keyPath`, NOT `path` — d3's HierarchyNode already has a `.path(target)`
|
|
1367
|
+
// method, and an own property would silently shadow it.
|
|
1368
|
+
n.keyPath =
|
|
1369
|
+
n.depth === 0
|
|
1370
|
+
? []
|
|
1371
|
+
: [...n.parent.keyPath, { key: d.key, value: d.value }];
|
|
1372
|
+
n.id = idOfPath(n.keyPath);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
// Post-order: a node's count and leaf interval aggregate its CHILDREN's, so
|
|
1376
|
+
// children go first.
|
|
1377
|
+
root.eachAfter((n) => {
|
|
1378
|
+
if (!n.children?.length) {
|
|
1379
|
+
n.count = n.data.ownCount ?? n.data.count ?? 0;
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
n.count = 0;
|
|
1383
|
+
n.leafStart = Infinity;
|
|
1384
|
+
n.leafEnd = -Infinity;
|
|
1385
|
+
for (const c of n.children) {
|
|
1386
|
+
n.count += c.count;
|
|
1387
|
+
if (c.leafStart < n.leafStart) n.leafStart = c.leafStart;
|
|
1388
|
+
if (c.leafEnd > n.leafEnd) n.leafEnd = c.leafEnd;
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
root.leaves_ = leaves;
|
|
1393
|
+
return root;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const defaultLabel = (value) => (value == null ? "" : String(value));
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Normalize either input shape into one decorated hierarchy.
|
|
1400
|
+
*
|
|
1401
|
+
* @param {object} spec
|
|
1402
|
+
* @param {any[]} [spec.data] flat, pre-sorted rows (shape A)
|
|
1403
|
+
* @param {string[]} [spec.keys] the levels, outermost first (shape A)
|
|
1404
|
+
* @param {any} [spec.root] a nested tree (shape B)
|
|
1405
|
+
* @param {(d:any)=>any[]} [spec.children] child accessor (shape B)
|
|
1406
|
+
* @param {(row:any, key:string)=>any} [spec.value] how to read a key off a row
|
|
1407
|
+
* @param {(d:any)=>number} [spec.count] leaf mass; defaults to `d.count ?? 1`
|
|
1408
|
+
* @param {(value:any, key:string, node:any)=>string} [spec.label]
|
|
1409
|
+
* @returns {import("d3-hierarchy").HierarchyNode<NodeDatum> & {leaves_: any[]}}
|
|
1410
|
+
*/
|
|
1411
|
+
function normalize$1(spec = {}) {
|
|
1412
|
+
const {
|
|
1413
|
+
data,
|
|
1414
|
+
keys,
|
|
1415
|
+
root: treeRoot,
|
|
1416
|
+
children = (d) => d.children,
|
|
1417
|
+
value = (row, key) => row[key],
|
|
1418
|
+
count = (d) => d.count ?? 1,
|
|
1419
|
+
label = defaultLabel,
|
|
1420
|
+
headers = false,
|
|
1421
|
+
} = spec;
|
|
1422
|
+
|
|
1423
|
+
const maybeHeaders = (raw, childrenOf) =>
|
|
1424
|
+
headers ? withHeaders(raw, childrenOf) : raw;
|
|
1425
|
+
|
|
1426
|
+
let root;
|
|
1427
|
+
if (treeRoot != null) {
|
|
1428
|
+
root = hierarchy(maybeHeaders(treeRoot, children), (d) =>
|
|
1429
|
+
headers ? d.children : children(d),
|
|
1430
|
+
);
|
|
1431
|
+
// Shape B nodes carry their own key/value/count; mirror them onto the
|
|
1432
|
+
// NodeDatum contract so `decorate` and the renderers stay shape-agnostic.
|
|
1433
|
+
root.each((n) => {
|
|
1434
|
+
const d = n.data ?? {};
|
|
1435
|
+
const of = d.__header ? d.__of : d;
|
|
1436
|
+
n.data = {
|
|
1437
|
+
key: of.key ?? null,
|
|
1438
|
+
value: of.value ?? of.name ?? of.id ?? null,
|
|
1439
|
+
row: of,
|
|
1440
|
+
header: !!d.__header,
|
|
1441
|
+
ownCount: d.__header ? 0 : n.children?.length ? undefined : count(of),
|
|
1442
|
+
};
|
|
1443
|
+
});
|
|
1444
|
+
} else if (Array.isArray(data) && Array.isArray(keys)) {
|
|
1445
|
+
root = hierarchy(
|
|
1446
|
+
maybeHeaders(rollup(data, keys, value, count), (d) => d.children),
|
|
1447
|
+
(d) => d.children,
|
|
1448
|
+
);
|
|
1449
|
+
if (headers) {
|
|
1450
|
+
root.each((n) => {
|
|
1451
|
+
if (!n.data.__header) return;
|
|
1452
|
+
const of = n.data.__of;
|
|
1453
|
+
n.data = {
|
|
1454
|
+
key: of.key,
|
|
1455
|
+
value: of.value,
|
|
1456
|
+
row: of.row,
|
|
1457
|
+
header: true,
|
|
1458
|
+
ownCount: 0,
|
|
1459
|
+
};
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
} else {
|
|
1463
|
+
// An empty widget is a normal state (no library loaded yet), not an error.
|
|
1464
|
+
root = hierarchy({ key: null, value: null, children: [], ownCount: 0 });
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
return decorate(root, label);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** The ordered leaf list — the sequence the fisheye lens distorts. */
|
|
1471
|
+
function leavesOf(root) {
|
|
1472
|
+
return root.leaves_ ?? root.leaves();
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** Every node except the synthetic root, in depth-first (visual) order. */
|
|
1476
|
+
function nodesOf(root) {
|
|
1477
|
+
const out = [];
|
|
1478
|
+
walk(root, (n) => {
|
|
1479
|
+
if (n.depth > 0) out.push(n);
|
|
1480
|
+
});
|
|
1481
|
+
return out;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Stage 1: choose the SLOTS — the contiguous partition of the leaf axis that
|
|
1486
|
+
* the positioner lays out. A slot is one of:
|
|
1487
|
+
*
|
|
1488
|
+
* leaf one real leaf
|
|
1489
|
+
* collapsed one closed subtree, standing in for all its leaves ("2019 · 1,204")
|
|
1490
|
+
* elision a RUN of adjacent closed siblings, merged ("4 more groups")
|
|
1491
|
+
*
|
|
1492
|
+
* Two selectors, same output shape, so the positioner and renderers never learn
|
|
1493
|
+
* which one ran:
|
|
1494
|
+
*
|
|
1495
|
+
* selectAll every leaf is its own slot. Honest, proportional, no elision.
|
|
1496
|
+
* selectDOI Furnas degree-of-interest: score the leaves, keep the best ones
|
|
1497
|
+
* that fit the row budget, close everything else.
|
|
1498
|
+
*
|
|
1499
|
+
* Why score LEAVES and not internal nodes: a selector that can only choose which
|
|
1500
|
+
* subtrees to *open* cannot be bounded. To show the focused leaf you must open
|
|
1501
|
+
* its whole ancestor chain, and each open node drags in its siblings — on a
|
|
1502
|
+
* 10-year library that is already 48 rows before you have chosen anything. So
|
|
1503
|
+
* the budget has to bite at the leaf level, and structure falls out of it:
|
|
1504
|
+
* a node is open iff it still holds a kept leaf.
|
|
1505
|
+
*/
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
/** @typedef {{node: any|null, parent?: any, nodes?: any[], leafStart: number,
|
|
1509
|
+
* leafEnd: number, count?: number}} Slot */
|
|
1510
|
+
|
|
1511
|
+
/** One slot per leaf. Carries `count` like every other slot — `sizeBy: "count"`
|
|
1512
|
+
* weights the column by slot mass, and a slot without it silently weighs zero. */
|
|
1513
|
+
function selectAll(root) {
|
|
1514
|
+
return leavesOf(root).map((node) => ({
|
|
1515
|
+
node,
|
|
1516
|
+
leafStart: node.leafStart,
|
|
1517
|
+
leafEnd: node.leafEnd,
|
|
1518
|
+
count: node.count,
|
|
1519
|
+
}));
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** Distance from a node's leaf interval to the focused leaf; 0 if it contains it. */
|
|
1523
|
+
function leafDistance(node, focusLeaf) {
|
|
1524
|
+
if (focusLeaf >= node.leafStart && focusLeaf < node.leafEnd) return 0;
|
|
1525
|
+
return focusLeaf < node.leafStart
|
|
1526
|
+
? node.leafStart - focusLeaf
|
|
1527
|
+
: focusLeaf - (node.leafEnd - 1);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Hops from `a` up to the lowest common ancestor and back down to `b`. This is
|
|
1532
|
+
* Furnas's tree distance, and it is the thing a purely positional distance
|
|
1533
|
+
* cannot see: the day before the focused day might be three rows up but a whole
|
|
1534
|
+
* YEAR away in the tree, while the focused day's own siblings are structurally
|
|
1535
|
+
* adjacent no matter where they land on the axis.
|
|
1536
|
+
*/
|
|
1537
|
+
function treeDistance(a, b) {
|
|
1538
|
+
let x = a;
|
|
1539
|
+
let y = b;
|
|
1540
|
+
let d = 0;
|
|
1541
|
+
while (x.depth > y.depth) {
|
|
1542
|
+
x = x.parent;
|
|
1543
|
+
d++;
|
|
1544
|
+
}
|
|
1545
|
+
while (y.depth > x.depth) {
|
|
1546
|
+
y = y.parent;
|
|
1547
|
+
d++;
|
|
1548
|
+
}
|
|
1549
|
+
while (x !== y && x.parent && y.parent) {
|
|
1550
|
+
x = x.parent;
|
|
1551
|
+
y = y.parent;
|
|
1552
|
+
d += 2;
|
|
1553
|
+
}
|
|
1554
|
+
return d;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Furnas's degree of interest: how badly does the user want to see this leaf,
|
|
1559
|
+
* given where they are looking?
|
|
1560
|
+
*
|
|
1561
|
+
* DOI(leaf) = importance(leaf) - distance(leaf, focus) + mass(leaf)
|
|
1562
|
+
*
|
|
1563
|
+
* Leaves are ranked by this and the best ones that fit the budget keep a full
|
|
1564
|
+
* row; the rest close into their ancestors. This function alone decides what a
|
|
1565
|
+
* DOI tree FEELS like, so each term is deliberate:
|
|
1566
|
+
*
|
|
1567
|
+
* - **importance** is depth measured RELATIVE to the focus, not absolutely. The
|
|
1568
|
+
* textbook `-depth` says a deep leaf matters less — true for the *root*'s
|
|
1569
|
+
* importance, false for leaves. In a ragged tree a depth-5 leaf isn't less
|
|
1570
|
+
* interesting than a depth-2 one, it's just more nested; what actually reads
|
|
1571
|
+
* badly is jumping between levels, so we penalise the level CHANGE.
|
|
1572
|
+
*
|
|
1573
|
+
* - **distance** blends the two notions of "far", because each alone fails:
|
|
1574
|
+
* the leaf axis alone gives a clean readable window but is blind to
|
|
1575
|
+
* structure; the tree alone keeps the focus's siblings but scatters the
|
|
1576
|
+
* survivors across the column. `treeWeight` slides between them.
|
|
1577
|
+
* Axis distance goes through `log1p` so the falloff is smooth — a linear
|
|
1578
|
+
* penalty makes rank flip sharply as the focus moves, which is exactly what
|
|
1579
|
+
* makes rows pop in and out while you drag the cursor.
|
|
1580
|
+
*
|
|
1581
|
+
* - **mass** is a weak tiebreaker, never a driver. Let it dominate and the lens
|
|
1582
|
+
* stops being about where you are and becomes a popularity chart.
|
|
1583
|
+
*
|
|
1584
|
+
* @param {any} leaf hierarchy node (.depth, .count, .leafStart, .leafEnd, .parent)
|
|
1585
|
+
* @param {{leaf: number, node: any}} focus focused leaf index and its node
|
|
1586
|
+
* @param {{apiWeight, distanceWeight, countWeight, treeWeight}} opts
|
|
1587
|
+
* @returns {number} higher = more interesting = more likely to keep its own row
|
|
1588
|
+
*/
|
|
1589
|
+
function doiScore(leaf, focus, opts) {
|
|
1590
|
+
const importance = -Math.abs(leaf.depth - focus.node.depth) * opts.apiWeight;
|
|
1591
|
+
|
|
1592
|
+
const axis = Math.log1p(leafDistance(leaf, focus.leaf));
|
|
1593
|
+
const tree = treeDistance(leaf, focus.node);
|
|
1594
|
+
const t = opts.treeWeight;
|
|
1595
|
+
const distance = ((1 - t) * axis + t * tree) * opts.distanceWeight;
|
|
1596
|
+
|
|
1597
|
+
const mass = Math.log1p(leaf.count) * opts.countWeight;
|
|
1598
|
+
|
|
1599
|
+
return importance - distance + mass;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Keep the highest-DOI leaves that fit the row budget; close the rest.
|
|
1604
|
+
*
|
|
1605
|
+
* Guarantees the property tests lean on:
|
|
1606
|
+
* - slots partition [0, nLeaves) contiguously, so mass is always conserved;
|
|
1607
|
+
* - the focused leaf ALWAYS keeps its own slot — you can never lose the row you
|
|
1608
|
+
* are pointing at, whatever the budget;
|
|
1609
|
+
* - slot count <= budget (that is what the K search below is for).
|
|
1610
|
+
*
|
|
1611
|
+
* @param {any} root
|
|
1612
|
+
* @param {{budget: number, focusLeaf: number} & Record<string, number>} opts
|
|
1613
|
+
* @returns {Slot[]}
|
|
1614
|
+
*/
|
|
1615
|
+
function selectDOI(root, opts) {
|
|
1616
|
+
const leaves = leavesOf(root);
|
|
1617
|
+
const n = leaves.length;
|
|
1618
|
+
if (!n) return [];
|
|
1619
|
+
|
|
1620
|
+
const budget = Math.max(1, Math.floor(opts.budget ?? n));
|
|
1621
|
+
if (n <= budget) return selectAll(root);
|
|
1622
|
+
|
|
1623
|
+
const focusLeaf = Math.max(0, Math.min(opts.focusLeaf ?? 0, n - 1));
|
|
1624
|
+
const focus = { leaf: focusLeaf, node: leaves[focusLeaf] };
|
|
1625
|
+
|
|
1626
|
+
// Rank once. Header cells are excluded: they are force-kept whenever their
|
|
1627
|
+
// node is open, so ranking them would just burn budget entries on rows we were
|
|
1628
|
+
// going to draw anyway. Ties break toward the focus, then by leaf order, so the
|
|
1629
|
+
// result is deterministic (a property test asserts it).
|
|
1630
|
+
const ranked = leaves
|
|
1631
|
+
.map((leaf, i) => ({ leaf, i, score: doiScore(leaf, focus, opts) }))
|
|
1632
|
+
.filter(({ leaf }) => !leaf.data?.header)
|
|
1633
|
+
.sort(
|
|
1634
|
+
(a, b) =>
|
|
1635
|
+
b.score - a.score ||
|
|
1636
|
+
Math.abs(a.i - focusLeaf) - Math.abs(b.i - focusLeaf) ||
|
|
1637
|
+
a.i - b.i,
|
|
1638
|
+
);
|
|
1639
|
+
|
|
1640
|
+
// Closing leaves ADDS rows back (a closed subtree is still one row), so K is
|
|
1641
|
+
// not simply the budget. Walk K down until the realized slot count fits. It
|
|
1642
|
+
// converges fast — closed rows only ever shrink as K shrinks.
|
|
1643
|
+
for (let K = Math.min(budget, n); K >= 1; K--) {
|
|
1644
|
+
const keep = new Set(ranked.slice(0, K).map((r) => r.leaf));
|
|
1645
|
+
keep.add(focus.node);
|
|
1646
|
+
const slots = buildSlots(root, keep);
|
|
1647
|
+
if (slots.length <= budget) return slots;
|
|
1648
|
+
}
|
|
1649
|
+
return buildSlots(root, new Set([focus.node]));
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Turn a set of kept leaves into the slot partition.
|
|
1654
|
+
*
|
|
1655
|
+
* A node is OPEN iff it still contains a kept leaf. Under an open node, each
|
|
1656
|
+
* child is either open (recurse) or closed (one slot). Adjacent closed children
|
|
1657
|
+
* merge into a single elision slot — without that merge, a wide node whose
|
|
1658
|
+
* children are all closed would spend one row per child and blow the budget it
|
|
1659
|
+
* was supposed to be saving.
|
|
1660
|
+
*/
|
|
1661
|
+
function buildSlots(root, keep) {
|
|
1662
|
+
const holdsKept = new Map();
|
|
1663
|
+
root.eachAfter((n) => {
|
|
1664
|
+
if (!n.children?.length) return holdsKept.set(n, keep.has(n));
|
|
1665
|
+
holdsKept.set(
|
|
1666
|
+
n,
|
|
1667
|
+
n.children.some((c) => holdsKept.get(c)),
|
|
1668
|
+
);
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
const slots = [];
|
|
1672
|
+
let run = null; // the closed run currently being accumulated
|
|
1673
|
+
|
|
1674
|
+
const flush = () => {
|
|
1675
|
+
if (!run) return;
|
|
1676
|
+
slots.push(
|
|
1677
|
+
run.nodes.length === 1
|
|
1678
|
+
? {
|
|
1679
|
+
node: run.nodes[0],
|
|
1680
|
+
leafStart: run.leafStart,
|
|
1681
|
+
leafEnd: run.leafEnd,
|
|
1682
|
+
count: run.count,
|
|
1683
|
+
}
|
|
1684
|
+
: {
|
|
1685
|
+
node: null,
|
|
1686
|
+
parent: run.parent,
|
|
1687
|
+
nodes: run.nodes,
|
|
1688
|
+
leafStart: run.leafStart,
|
|
1689
|
+
leafEnd: run.leafEnd,
|
|
1690
|
+
count: run.count,
|
|
1691
|
+
},
|
|
1692
|
+
);
|
|
1693
|
+
run = null;
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
(function descend(node) {
|
|
1697
|
+
for (const child of node.children ?? []) {
|
|
1698
|
+
// A header always survives while its parent is open — a node that shows
|
|
1699
|
+
// its children but not its own label is worse than useless.
|
|
1700
|
+
const isHeader = child.data?.header;
|
|
1701
|
+
const open = holdsKept.get(child) && child.children?.length;
|
|
1702
|
+
const kept =
|
|
1703
|
+
isHeader || (holdsKept.get(child) && !child.children?.length);
|
|
1704
|
+
|
|
1705
|
+
if (open || kept) {
|
|
1706
|
+
flush(); // a visible child breaks the closed run
|
|
1707
|
+
if (open) descend(child);
|
|
1708
|
+
else
|
|
1709
|
+
slots.push({
|
|
1710
|
+
node: child,
|
|
1711
|
+
leafStart: child.leafStart,
|
|
1712
|
+
leafEnd: child.leafEnd,
|
|
1713
|
+
count: child.count,
|
|
1714
|
+
});
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// Closed: accumulate into the current run of adjacent closed siblings.
|
|
1719
|
+
if (run && run.parent === node) {
|
|
1720
|
+
run.nodes.push(child);
|
|
1721
|
+
run.leafEnd = child.leafEnd;
|
|
1722
|
+
run.count += child.count;
|
|
1723
|
+
} else {
|
|
1724
|
+
flush();
|
|
1725
|
+
run = {
|
|
1726
|
+
parent: node,
|
|
1727
|
+
nodes: [child],
|
|
1728
|
+
leafStart: child.leafStart,
|
|
1729
|
+
leafEnd: child.leafEnd,
|
|
1730
|
+
count: child.count,
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
flush(); // a run never spans out of its parent — that would break containment
|
|
1735
|
+
})(root);
|
|
1736
|
+
|
|
1737
|
+
return slots;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* Stage 2 of the layout: turn a slot sequence into pixel boundaries.
|
|
1742
|
+
*
|
|
1743
|
+
* Two steps, deliberately separate:
|
|
1744
|
+
*
|
|
1745
|
+
* basePositions() how much column each slot gets BEFORE any lens. Either
|
|
1746
|
+
* equal spans (`sizeBy: "slots"` — size encodes interest) or
|
|
1747
|
+
* spans proportional to mass (`sizeBy: "count"` — size encodes
|
|
1748
|
+
* photo count, a true value-encoded partition diagram).
|
|
1749
|
+
* distort() the fisheye, applied on top of whatever base it is given.
|
|
1750
|
+
*
|
|
1751
|
+
* The fisheye only needs its input to be monotonic, so it composes with either
|
|
1752
|
+
* base for free. `x === a` stays a fixed point, which is what keeps the row under
|
|
1753
|
+
* the cursor the row you click — mass-weighted or not.
|
|
1754
|
+
*
|
|
1755
|
+
* A slot sequence of length S gets S+1 boundaries spanning [min, max]. Slot j
|
|
1756
|
+
* owns [pos[j], pos[j+1]], so the last slot gets a real band instead of
|
|
1757
|
+
* collapsing onto the bottom edge.
|
|
1758
|
+
*/
|
|
1759
|
+
|
|
1760
|
+
const SIZE_BY = ["slots", "count"];
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* The fisheye scale, ported verbatim from AutoGallery's `fisheye.js`, which
|
|
1764
|
+
* ported it from PhotoRing's `d3_fisheye_scale` (d3 v3). Maps a base position
|
|
1765
|
+
* `x` in [min, max] to a distorted position that magnifies around focus pixel
|
|
1766
|
+
* `a` and compresses away from it. Monotonic in `x`; `x === a` is a fixed point;
|
|
1767
|
+
* the endpoints map to themselves. Larger `d` = gentler distortion.
|
|
1768
|
+
*
|
|
1769
|
+
* This math is correct and well covered — it is the one piece carried over
|
|
1770
|
+
* untouched.
|
|
1771
|
+
*
|
|
1772
|
+
* @returns {number}
|
|
1773
|
+
*/
|
|
1774
|
+
function fisheyePosition(x, a, min, max, d) {
|
|
1775
|
+
const left = x < a;
|
|
1776
|
+
let m = left ? a - min : max - a;
|
|
1777
|
+
if (m === 0) m = max - min;
|
|
1778
|
+
const dx = Math.abs(x - a);
|
|
1779
|
+
if (dx < 1e-9) return a;
|
|
1780
|
+
return ((left ? -1 : 1) * (m * (d + 1))) / (d + m / dx) + a;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Base (undistorted) boundaries for a sequence of weighted items.
|
|
1787
|
+
*
|
|
1788
|
+
* `sizeBy: "count"` is the honest icicle: a 40,000-photo year visibly dwarfs a
|
|
1789
|
+
* 400-photo one. But a pure mass split gives a zero-count group a zero-height
|
|
1790
|
+
* band — invisible, and impossible to click. So every item is guaranteed
|
|
1791
|
+
* `sizeFloor` of an equal share, and only the remainder is divided by mass:
|
|
1792
|
+
*
|
|
1793
|
+
* span(j) = (1 - sizeFloor) * mass(j)/total + sizeFloor * 1/S
|
|
1794
|
+
*
|
|
1795
|
+
* sizeFloor = 1 collapses back to equal spans, so the two modes are the ends of
|
|
1796
|
+
* one continuum rather than two code paths.
|
|
1797
|
+
*
|
|
1798
|
+
* @param {Array<{count?: number}>} items
|
|
1799
|
+
* @param {{min:number, max:number, sizeBy?:string, sizeFloor?:number}} o
|
|
1800
|
+
* @returns {number[]} S+1 boundaries
|
|
1801
|
+
*/
|
|
1802
|
+
function basePositions(
|
|
1803
|
+
items,
|
|
1804
|
+
{ min, max, sizeBy = "slots", sizeFloor = 0.25 },
|
|
1805
|
+
) {
|
|
1806
|
+
const S = items.length;
|
|
1807
|
+
const span = max - min;
|
|
1808
|
+
const pos = new Array(S + 1);
|
|
1809
|
+
pos[0] = min;
|
|
1810
|
+
|
|
1811
|
+
if (sizeBy !== "count") {
|
|
1812
|
+
for (let j = 1; j <= S; j++) pos[j] = min + (span * j) / S;
|
|
1813
|
+
pos[S] = max;
|
|
1814
|
+
return pos;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const total = items.reduce((s, it) => s + Math.max(0, it.count ?? 0), 0);
|
|
1818
|
+
const f = total > 0 ? clamp(sizeFloor, 0, 1) : 1; // no mass anywhere -> equal
|
|
1819
|
+
let acc = min;
|
|
1820
|
+
for (let j = 0; j < S; j++) {
|
|
1821
|
+
const mass = total > 0 ? Math.max(0, items[j].count ?? 0) / total : 0;
|
|
1822
|
+
acc += span * ((1 - f) * mass + f / S);
|
|
1823
|
+
pos[j + 1] = acc;
|
|
1824
|
+
}
|
|
1825
|
+
pos[S] = max; // absorb float drift into the endpoint, which is pinned anyway
|
|
1826
|
+
return pos;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** The slot whose base interval contains pixel `px`. Works for ANY spacing —
|
|
1830
|
+
* which is why this is a search and not a `scale.invert()`. */
|
|
1831
|
+
function slotAtPixel(basePos, px) {
|
|
1832
|
+
const S = basePos.length - 1;
|
|
1833
|
+
if (S <= 0) return 0;
|
|
1834
|
+
let lo = 0;
|
|
1835
|
+
let hi = S - 1;
|
|
1836
|
+
while (lo < hi) {
|
|
1837
|
+
const mid = (lo + hi + 1) >> 1;
|
|
1838
|
+
if (basePos[mid] <= px) lo = mid;
|
|
1839
|
+
else hi = mid - 1;
|
|
1840
|
+
}
|
|
1841
|
+
return lo;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* Resolve the focus into BOTH representations:
|
|
1846
|
+
* `px` the focus pixel (what the lens magnifies around)
|
|
1847
|
+
* `slot` the slot sitting under that pixel (what gets highlighted)
|
|
1848
|
+
*
|
|
1849
|
+
* When the host pins the focus to the cursor (`focus.px`), the pixel is
|
|
1850
|
+
* authoritative and the slot is derived — that is what keeps the magnified row
|
|
1851
|
+
* exactly under the pointer, so a click never lands on a row that slid away.
|
|
1852
|
+
* When the host drives the focus from its own state (`focus.slot`), the slot is
|
|
1853
|
+
* authoritative and the pixel is derived (the middle of its band).
|
|
1854
|
+
*/
|
|
1855
|
+
function resolveFocus(focus, basePos) {
|
|
1856
|
+
const S = basePos.length - 1;
|
|
1857
|
+
const min = basePos[0];
|
|
1858
|
+
const max = basePos[S];
|
|
1859
|
+
if (S <= 0) return { px: min, slot: 0 };
|
|
1860
|
+
|
|
1861
|
+
if (focus?.px != null) {
|
|
1862
|
+
const px = clamp(focus.px, min, max);
|
|
1863
|
+
return { px, slot: slotAtPixel(basePos, px) };
|
|
1864
|
+
}
|
|
1865
|
+
const slot = clamp(focus?.slot ?? 0, 0, S - 1);
|
|
1866
|
+
return { px: (basePos[slot] + basePos[slot + 1]) / 2, slot };
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/** Apply the fisheye to a base boundary array. */
|
|
1870
|
+
function distort(basePos, { focusPx, distortion }) {
|
|
1871
|
+
const S = basePos.length - 1;
|
|
1872
|
+
const min = basePos[0];
|
|
1873
|
+
const max = basePos[S];
|
|
1874
|
+
const a = clamp(focusPx, min, max);
|
|
1875
|
+
const pos = basePos.map((x) => fisheyePosition(x, a, min, max, distortion));
|
|
1876
|
+
// The scale pins the endpoints analytically; clamp anyway so floating-point
|
|
1877
|
+
// drift can never push a band a hair outside the padded column.
|
|
1878
|
+
pos[0] = min;
|
|
1879
|
+
pos[S] = max;
|
|
1880
|
+
return pos;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* Stage 3: slots + pixel boundaries -> rows.
|
|
1885
|
+
*
|
|
1886
|
+
* This is where "parents contain their children" stops being something a
|
|
1887
|
+
* renderer has to be careful about. Every node already owns a leaf interval
|
|
1888
|
+
* (`leafStart`..`leafEnd`, from hierarchy.js), and the slots partition that same
|
|
1889
|
+
* leaf axis. So a node's band is just the pixel span of the slots its leaf
|
|
1890
|
+
* interval covers — and since a child's leaf interval is a SUBSET of its
|
|
1891
|
+
* parent's, its band is necessarily inside its parent's. Containment is
|
|
1892
|
+
* arithmetic, not diligence.
|
|
1893
|
+
*
|
|
1894
|
+
* A node is drawn iff its leaf interval is a whole number of slots AND no closed
|
|
1895
|
+
* ancestor is standing in for it.
|
|
1896
|
+
*/
|
|
1897
|
+
|
|
1898
|
+
/** @typedef {{node: any|null, parent?: any, nodes?: any[], leafStart: number,
|
|
1899
|
+
* leafEnd: number, count?: number}} Slot */
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* @param {any} root decorated hierarchy (see hierarchy.js)
|
|
1903
|
+
* @param {Slot[]} slots contiguous partition of the leaf axis
|
|
1904
|
+
* @param {number[]} pos slots.length + 1 pixel boundaries
|
|
1905
|
+
* @param {{focusSlot?: number}} [opts]
|
|
1906
|
+
* @returns {{rows: any[], maxCount: number}}
|
|
1907
|
+
*/
|
|
1908
|
+
function buildRows(root, slots, pos, opts = {}) {
|
|
1909
|
+
const S = slots.length;
|
|
1910
|
+
if (!S) return { rows: [], maxCount: 0 };
|
|
1911
|
+
|
|
1912
|
+
const nLeaves = slots[S - 1].leafEnd;
|
|
1913
|
+
const slotOfLeaf = new Int32Array(nLeaves);
|
|
1914
|
+
for (let j = 0; j < S; j++) {
|
|
1915
|
+
for (let i = slots[j].leafStart; i < slots[j].leafEnd; i++)
|
|
1916
|
+
slotOfLeaf[i] = j;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// Nodes that a closed slot is standing in for. Without this, a chain like
|
|
1920
|
+
// 2019 > 12 > 31 that is closed at "2019" would draw all three bands stacked
|
|
1921
|
+
// on the same pixels — they all happen to span exactly one slot.
|
|
1922
|
+
const hidden = new Set();
|
|
1923
|
+
const hide = (n) => n.each?.((d) => d !== n && hidden.add(d));
|
|
1924
|
+
for (const slot of slots) {
|
|
1925
|
+
if (slot.node?.children?.length) hide(slot.node);
|
|
1926
|
+
if (slot.nodes) for (const n of slot.nodes) n.each((d) => hidden.add(d));
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const focusSlot = opts.focusSlot;
|
|
1930
|
+
const rows = [];
|
|
1931
|
+
let maxCount = 0;
|
|
1932
|
+
|
|
1933
|
+
const push = (row) => {
|
|
1934
|
+
rows.push(row);
|
|
1935
|
+
if (row.terminal && row.count > maxCount) maxCount = row.count;
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
const band = (sStart, sEnd) => {
|
|
1939
|
+
const y0 = pos[sStart];
|
|
1940
|
+
const y1 = pos[sEnd];
|
|
1941
|
+
return {
|
|
1942
|
+
slotStart: sStart,
|
|
1943
|
+
slotEnd: sEnd,
|
|
1944
|
+
leafStart: slots[sStart].leafStart,
|
|
1945
|
+
leafEnd: slots[sEnd - 1].leafEnd,
|
|
1946
|
+
focused: focusSlot != null && focusSlot >= sStart && focusSlot < sEnd,
|
|
1947
|
+
y0,
|
|
1948
|
+
y1,
|
|
1949
|
+
y: (y0 + y1) / 2,
|
|
1950
|
+
thickness: y1 - y0,
|
|
1951
|
+
};
|
|
1952
|
+
};
|
|
1953
|
+
|
|
1954
|
+
root.each((node) => {
|
|
1955
|
+
if (node.depth === 0) return; // the synthetic root is not a row
|
|
1956
|
+
if (hidden.has(node)) return;
|
|
1957
|
+
if (node.leafStart >= nLeaves) return;
|
|
1958
|
+
|
|
1959
|
+
const sStart = slotOfLeaf[node.leafStart];
|
|
1960
|
+
const sEnd = slotOfLeaf[node.leafEnd - 1] + 1;
|
|
1961
|
+
const aligned =
|
|
1962
|
+
slots[sStart].leafStart === node.leafStart &&
|
|
1963
|
+
slots[sEnd - 1].leafEnd === node.leafEnd;
|
|
1964
|
+
if (!aligned) return;
|
|
1965
|
+
|
|
1966
|
+
const slot = slots[sStart];
|
|
1967
|
+
const collapsed =
|
|
1968
|
+
sEnd - sStart === 1 && slot.node === node && !!node.children?.length;
|
|
1969
|
+
// Terminal rows carry the count histogram: real leaves and closed subtrees.
|
|
1970
|
+
// Everything else is structure.
|
|
1971
|
+
const terminal = collapsed || !node.children?.length;
|
|
1972
|
+
|
|
1973
|
+
push({
|
|
1974
|
+
node,
|
|
1975
|
+
parentNode: node.parent,
|
|
1976
|
+
id: node.id,
|
|
1977
|
+
depth: node.depth,
|
|
1978
|
+
label: node.label,
|
|
1979
|
+
path: node.keyPath,
|
|
1980
|
+
count: node.count,
|
|
1981
|
+
kind: node.data?.header
|
|
1982
|
+
? "header"
|
|
1983
|
+
: collapsed
|
|
1984
|
+
? "collapsed"
|
|
1985
|
+
: terminal
|
|
1986
|
+
? "leaf"
|
|
1987
|
+
: "branch",
|
|
1988
|
+
terminal,
|
|
1989
|
+
elided: collapsed ? node.leafEnd - node.leafStart : 0,
|
|
1990
|
+
...band(sStart, sEnd),
|
|
1991
|
+
});
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
// Elision slots have no node of their own, so they are not reachable from the
|
|
1995
|
+
// walk above — emit them here.
|
|
1996
|
+
slots.forEach((slot, j) => {
|
|
1997
|
+
if (slot.node || !slot.nodes) return;
|
|
1998
|
+
const first = slot.nodes[0];
|
|
1999
|
+
push({
|
|
2000
|
+
node: null,
|
|
2001
|
+
parentNode: slot.parent,
|
|
2002
|
+
nodes: slot.nodes,
|
|
2003
|
+
id: `elision:${first.id}+${slot.nodes.length}`,
|
|
2004
|
+
depth: first.depth,
|
|
2005
|
+
label: `${slot.nodes.length} more`,
|
|
2006
|
+
path: slot.parent.keyPath,
|
|
2007
|
+
count: slot.count,
|
|
2008
|
+
kind: "elision",
|
|
2009
|
+
terminal: true,
|
|
2010
|
+
elided: slot.leafEnd - slot.leafStart,
|
|
2011
|
+
...band(j, j + 1),
|
|
2012
|
+
});
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
rows.sort((a, b) => a.y0 - b.y0 || a.depth - b.depth);
|
|
2016
|
+
return { rows, maxCount };
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function ascending(a, b) {
|
|
2020
|
+
return a == null || b == null ? NaN : a < b ? -1 : a > b ? 1 : a >= b ? 0 : NaN;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function descending(a, b) {
|
|
2024
|
+
return a == null || b == null ? NaN
|
|
2025
|
+
: b < a ? -1
|
|
2026
|
+
: b > a ? 1
|
|
2027
|
+
: b >= a ? 0
|
|
2028
|
+
: NaN;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
function bisector(f) {
|
|
2032
|
+
let compare1, compare2, delta;
|
|
2033
|
+
|
|
2034
|
+
// If an accessor is specified, promote it to a comparator. In this case we
|
|
2035
|
+
// can test whether the search value is (self-) comparable. We can’t do this
|
|
2036
|
+
// for a comparator (except for specific, known comparators) because we can’t
|
|
2037
|
+
// tell if the comparator is symmetric, and an asymmetric comparator can’t be
|
|
2038
|
+
// used to test whether a single value is comparable.
|
|
2039
|
+
if (f.length !== 2) {
|
|
2040
|
+
compare1 = ascending;
|
|
2041
|
+
compare2 = (d, x) => ascending(f(d), x);
|
|
2042
|
+
delta = (d, x) => f(d) - x;
|
|
2043
|
+
} else {
|
|
2044
|
+
compare1 = f === ascending || f === descending ? f : zero$1;
|
|
2045
|
+
compare2 = f;
|
|
2046
|
+
delta = f;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function left(a, x, lo = 0, hi = a.length) {
|
|
2050
|
+
if (lo < hi) {
|
|
2051
|
+
if (compare1(x, x) !== 0) return hi;
|
|
2052
|
+
do {
|
|
2053
|
+
const mid = (lo + hi) >>> 1;
|
|
2054
|
+
if (compare2(a[mid], x) < 0) lo = mid + 1;
|
|
2055
|
+
else hi = mid;
|
|
2056
|
+
} while (lo < hi);
|
|
2057
|
+
}
|
|
2058
|
+
return lo;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function right(a, x, lo = 0, hi = a.length) {
|
|
2062
|
+
if (lo < hi) {
|
|
2063
|
+
if (compare1(x, x) !== 0) return hi;
|
|
2064
|
+
do {
|
|
2065
|
+
const mid = (lo + hi) >>> 1;
|
|
2066
|
+
if (compare2(a[mid], x) <= 0) lo = mid + 1;
|
|
2067
|
+
else hi = mid;
|
|
2068
|
+
} while (lo < hi);
|
|
2069
|
+
}
|
|
2070
|
+
return lo;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function center(a, x, lo = 0, hi = a.length) {
|
|
2074
|
+
const i = left(a, x, lo, hi - 1);
|
|
2075
|
+
return i > lo && delta(a[i - 1], x) > -delta(a[i], x) ? i - 1 : i;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
return {left, center, right};
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function zero$1() {
|
|
2082
|
+
return 0;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function number$1(x) {
|
|
2086
|
+
return x === null ? NaN : +x;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
const ascendingBisect = bisector(ascending);
|
|
2090
|
+
const bisectRight = ascendingBisect.right;
|
|
2091
|
+
bisector(number$1).center;
|
|
2092
|
+
|
|
2093
|
+
const e10 = Math.sqrt(50),
|
|
2094
|
+
e5 = Math.sqrt(10),
|
|
2095
|
+
e2 = Math.sqrt(2);
|
|
2096
|
+
|
|
2097
|
+
function tickSpec(start, stop, count) {
|
|
2098
|
+
const step = (stop - start) / Math.max(0, count),
|
|
2099
|
+
power = Math.floor(Math.log10(step)),
|
|
2100
|
+
error = step / Math.pow(10, power),
|
|
2101
|
+
factor = error >= e10 ? 10 : error >= e5 ? 5 : error >= e2 ? 2 : 1;
|
|
2102
|
+
let i1, i2, inc;
|
|
2103
|
+
if (power < 0) {
|
|
2104
|
+
inc = Math.pow(10, -power) / factor;
|
|
2105
|
+
i1 = Math.round(start * inc);
|
|
2106
|
+
i2 = Math.round(stop * inc);
|
|
2107
|
+
if (i1 / inc < start) ++i1;
|
|
2108
|
+
if (i2 / inc > stop) --i2;
|
|
2109
|
+
inc = -inc;
|
|
2110
|
+
} else {
|
|
2111
|
+
inc = Math.pow(10, power) * factor;
|
|
2112
|
+
i1 = Math.round(start / inc);
|
|
2113
|
+
i2 = Math.round(stop / inc);
|
|
2114
|
+
if (i1 * inc < start) ++i1;
|
|
2115
|
+
if (i2 * inc > stop) --i2;
|
|
2116
|
+
}
|
|
2117
|
+
if (i2 < i1 && 0.5 <= count && count < 2) return tickSpec(start, stop, count * 2);
|
|
2118
|
+
return [i1, i2, inc];
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function ticks(start, stop, count) {
|
|
2122
|
+
stop = +stop, start = +start, count = +count;
|
|
2123
|
+
if (!(count > 0)) return [];
|
|
2124
|
+
if (start === stop) return [start];
|
|
2125
|
+
const reverse = stop < start, [i1, i2, inc] = reverse ? tickSpec(stop, start, count) : tickSpec(start, stop, count);
|
|
2126
|
+
if (!(i2 >= i1)) return [];
|
|
2127
|
+
const n = i2 - i1 + 1, ticks = new Array(n);
|
|
2128
|
+
if (reverse) {
|
|
2129
|
+
if (inc < 0) for (let i = 0; i < n; ++i) ticks[i] = (i2 - i) / -inc;
|
|
2130
|
+
else for (let i = 0; i < n; ++i) ticks[i] = (i2 - i) * inc;
|
|
2131
|
+
} else {
|
|
2132
|
+
if (inc < 0) for (let i = 0; i < n; ++i) ticks[i] = (i1 + i) / -inc;
|
|
2133
|
+
else for (let i = 0; i < n; ++i) ticks[i] = (i1 + i) * inc;
|
|
2134
|
+
}
|
|
2135
|
+
return ticks;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function tickIncrement(start, stop, count) {
|
|
2139
|
+
stop = +stop, start = +start, count = +count;
|
|
2140
|
+
return tickSpec(start, stop, count)[2];
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function tickStep(start, stop, count) {
|
|
2144
|
+
stop = +stop, start = +start, count = +count;
|
|
2145
|
+
const reverse = stop < start, inc = reverse ? tickIncrement(stop, start, count) : tickIncrement(start, stop, count);
|
|
2146
|
+
return (reverse ? -1 : 1) * (inc < 0 ? 1 / -inc : inc);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function initRange(domain, range) {
|
|
2150
|
+
switch (arguments.length) {
|
|
2151
|
+
case 0: break;
|
|
2152
|
+
case 1: this.range(domain); break;
|
|
2153
|
+
default: this.range(range).domain(domain); break;
|
|
2154
|
+
}
|
|
2155
|
+
return this;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function define(constructor, factory, prototype) {
|
|
2159
|
+
constructor.prototype = factory.prototype = prototype;
|
|
2160
|
+
prototype.constructor = constructor;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function extend(parent, definition) {
|
|
2164
|
+
var prototype = Object.create(parent.prototype);
|
|
2165
|
+
for (var key in definition) prototype[key] = definition[key];
|
|
2166
|
+
return prototype;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function Color() {}
|
|
2170
|
+
|
|
2171
|
+
var darker = 0.7;
|
|
2172
|
+
var brighter = 1 / darker;
|
|
2173
|
+
|
|
2174
|
+
var reI = "\\s*([+-]?\\d+)\\s*",
|
|
2175
|
+
reN = "\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",
|
|
2176
|
+
reP = "\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",
|
|
2177
|
+
reHex = /^#([0-9a-f]{3,8})$/,
|
|
2178
|
+
reRgbInteger = new RegExp(`^rgb\\(${reI},${reI},${reI}\\)$`),
|
|
2179
|
+
reRgbPercent = new RegExp(`^rgb\\(${reP},${reP},${reP}\\)$`),
|
|
2180
|
+
reRgbaInteger = new RegExp(`^rgba\\(${reI},${reI},${reI},${reN}\\)$`),
|
|
2181
|
+
reRgbaPercent = new RegExp(`^rgba\\(${reP},${reP},${reP},${reN}\\)$`),
|
|
2182
|
+
reHslPercent = new RegExp(`^hsl\\(${reN},${reP},${reP}\\)$`),
|
|
2183
|
+
reHslaPercent = new RegExp(`^hsla\\(${reN},${reP},${reP},${reN}\\)$`);
|
|
2184
|
+
|
|
2185
|
+
var named = {
|
|
2186
|
+
aliceblue: 0xf0f8ff,
|
|
2187
|
+
antiquewhite: 0xfaebd7,
|
|
2188
|
+
aqua: 0x00ffff,
|
|
2189
|
+
aquamarine: 0x7fffd4,
|
|
2190
|
+
azure: 0xf0ffff,
|
|
2191
|
+
beige: 0xf5f5dc,
|
|
2192
|
+
bisque: 0xffe4c4,
|
|
2193
|
+
black: 0x000000,
|
|
2194
|
+
blanchedalmond: 0xffebcd,
|
|
2195
|
+
blue: 0x0000ff,
|
|
2196
|
+
blueviolet: 0x8a2be2,
|
|
2197
|
+
brown: 0xa52a2a,
|
|
2198
|
+
burlywood: 0xdeb887,
|
|
2199
|
+
cadetblue: 0x5f9ea0,
|
|
2200
|
+
chartreuse: 0x7fff00,
|
|
2201
|
+
chocolate: 0xd2691e,
|
|
2202
|
+
coral: 0xff7f50,
|
|
2203
|
+
cornflowerblue: 0x6495ed,
|
|
2204
|
+
cornsilk: 0xfff8dc,
|
|
2205
|
+
crimson: 0xdc143c,
|
|
2206
|
+
cyan: 0x00ffff,
|
|
2207
|
+
darkblue: 0x00008b,
|
|
2208
|
+
darkcyan: 0x008b8b,
|
|
2209
|
+
darkgoldenrod: 0xb8860b,
|
|
2210
|
+
darkgray: 0xa9a9a9,
|
|
2211
|
+
darkgreen: 0x006400,
|
|
2212
|
+
darkgrey: 0xa9a9a9,
|
|
2213
|
+
darkkhaki: 0xbdb76b,
|
|
2214
|
+
darkmagenta: 0x8b008b,
|
|
2215
|
+
darkolivegreen: 0x556b2f,
|
|
2216
|
+
darkorange: 0xff8c00,
|
|
2217
|
+
darkorchid: 0x9932cc,
|
|
2218
|
+
darkred: 0x8b0000,
|
|
2219
|
+
darksalmon: 0xe9967a,
|
|
2220
|
+
darkseagreen: 0x8fbc8f,
|
|
2221
|
+
darkslateblue: 0x483d8b,
|
|
2222
|
+
darkslategray: 0x2f4f4f,
|
|
2223
|
+
darkslategrey: 0x2f4f4f,
|
|
2224
|
+
darkturquoise: 0x00ced1,
|
|
2225
|
+
darkviolet: 0x9400d3,
|
|
2226
|
+
deeppink: 0xff1493,
|
|
2227
|
+
deepskyblue: 0x00bfff,
|
|
2228
|
+
dimgray: 0x696969,
|
|
2229
|
+
dimgrey: 0x696969,
|
|
2230
|
+
dodgerblue: 0x1e90ff,
|
|
2231
|
+
firebrick: 0xb22222,
|
|
2232
|
+
floralwhite: 0xfffaf0,
|
|
2233
|
+
forestgreen: 0x228b22,
|
|
2234
|
+
fuchsia: 0xff00ff,
|
|
2235
|
+
gainsboro: 0xdcdcdc,
|
|
2236
|
+
ghostwhite: 0xf8f8ff,
|
|
2237
|
+
gold: 0xffd700,
|
|
2238
|
+
goldenrod: 0xdaa520,
|
|
2239
|
+
gray: 0x808080,
|
|
2240
|
+
green: 0x008000,
|
|
2241
|
+
greenyellow: 0xadff2f,
|
|
2242
|
+
grey: 0x808080,
|
|
2243
|
+
honeydew: 0xf0fff0,
|
|
2244
|
+
hotpink: 0xff69b4,
|
|
2245
|
+
indianred: 0xcd5c5c,
|
|
2246
|
+
indigo: 0x4b0082,
|
|
2247
|
+
ivory: 0xfffff0,
|
|
2248
|
+
khaki: 0xf0e68c,
|
|
2249
|
+
lavender: 0xe6e6fa,
|
|
2250
|
+
lavenderblush: 0xfff0f5,
|
|
2251
|
+
lawngreen: 0x7cfc00,
|
|
2252
|
+
lemonchiffon: 0xfffacd,
|
|
2253
|
+
lightblue: 0xadd8e6,
|
|
2254
|
+
lightcoral: 0xf08080,
|
|
2255
|
+
lightcyan: 0xe0ffff,
|
|
2256
|
+
lightgoldenrodyellow: 0xfafad2,
|
|
2257
|
+
lightgray: 0xd3d3d3,
|
|
2258
|
+
lightgreen: 0x90ee90,
|
|
2259
|
+
lightgrey: 0xd3d3d3,
|
|
2260
|
+
lightpink: 0xffb6c1,
|
|
2261
|
+
lightsalmon: 0xffa07a,
|
|
2262
|
+
lightseagreen: 0x20b2aa,
|
|
2263
|
+
lightskyblue: 0x87cefa,
|
|
2264
|
+
lightslategray: 0x778899,
|
|
2265
|
+
lightslategrey: 0x778899,
|
|
2266
|
+
lightsteelblue: 0xb0c4de,
|
|
2267
|
+
lightyellow: 0xffffe0,
|
|
2268
|
+
lime: 0x00ff00,
|
|
2269
|
+
limegreen: 0x32cd32,
|
|
2270
|
+
linen: 0xfaf0e6,
|
|
2271
|
+
magenta: 0xff00ff,
|
|
2272
|
+
maroon: 0x800000,
|
|
2273
|
+
mediumaquamarine: 0x66cdaa,
|
|
2274
|
+
mediumblue: 0x0000cd,
|
|
2275
|
+
mediumorchid: 0xba55d3,
|
|
2276
|
+
mediumpurple: 0x9370db,
|
|
2277
|
+
mediumseagreen: 0x3cb371,
|
|
2278
|
+
mediumslateblue: 0x7b68ee,
|
|
2279
|
+
mediumspringgreen: 0x00fa9a,
|
|
2280
|
+
mediumturquoise: 0x48d1cc,
|
|
2281
|
+
mediumvioletred: 0xc71585,
|
|
2282
|
+
midnightblue: 0x191970,
|
|
2283
|
+
mintcream: 0xf5fffa,
|
|
2284
|
+
mistyrose: 0xffe4e1,
|
|
2285
|
+
moccasin: 0xffe4b5,
|
|
2286
|
+
navajowhite: 0xffdead,
|
|
2287
|
+
navy: 0x000080,
|
|
2288
|
+
oldlace: 0xfdf5e6,
|
|
2289
|
+
olive: 0x808000,
|
|
2290
|
+
olivedrab: 0x6b8e23,
|
|
2291
|
+
orange: 0xffa500,
|
|
2292
|
+
orangered: 0xff4500,
|
|
2293
|
+
orchid: 0xda70d6,
|
|
2294
|
+
palegoldenrod: 0xeee8aa,
|
|
2295
|
+
palegreen: 0x98fb98,
|
|
2296
|
+
paleturquoise: 0xafeeee,
|
|
2297
|
+
palevioletred: 0xdb7093,
|
|
2298
|
+
papayawhip: 0xffefd5,
|
|
2299
|
+
peachpuff: 0xffdab9,
|
|
2300
|
+
peru: 0xcd853f,
|
|
2301
|
+
pink: 0xffc0cb,
|
|
2302
|
+
plum: 0xdda0dd,
|
|
2303
|
+
powderblue: 0xb0e0e6,
|
|
2304
|
+
purple: 0x800080,
|
|
2305
|
+
rebeccapurple: 0x663399,
|
|
2306
|
+
red: 0xff0000,
|
|
2307
|
+
rosybrown: 0xbc8f8f,
|
|
2308
|
+
royalblue: 0x4169e1,
|
|
2309
|
+
saddlebrown: 0x8b4513,
|
|
2310
|
+
salmon: 0xfa8072,
|
|
2311
|
+
sandybrown: 0xf4a460,
|
|
2312
|
+
seagreen: 0x2e8b57,
|
|
2313
|
+
seashell: 0xfff5ee,
|
|
2314
|
+
sienna: 0xa0522d,
|
|
2315
|
+
silver: 0xc0c0c0,
|
|
2316
|
+
skyblue: 0x87ceeb,
|
|
2317
|
+
slateblue: 0x6a5acd,
|
|
2318
|
+
slategray: 0x708090,
|
|
2319
|
+
slategrey: 0x708090,
|
|
2320
|
+
snow: 0xfffafa,
|
|
2321
|
+
springgreen: 0x00ff7f,
|
|
2322
|
+
steelblue: 0x4682b4,
|
|
2323
|
+
tan: 0xd2b48c,
|
|
2324
|
+
teal: 0x008080,
|
|
2325
|
+
thistle: 0xd8bfd8,
|
|
2326
|
+
tomato: 0xff6347,
|
|
2327
|
+
turquoise: 0x40e0d0,
|
|
2328
|
+
violet: 0xee82ee,
|
|
2329
|
+
wheat: 0xf5deb3,
|
|
2330
|
+
white: 0xffffff,
|
|
2331
|
+
whitesmoke: 0xf5f5f5,
|
|
2332
|
+
yellow: 0xffff00,
|
|
2333
|
+
yellowgreen: 0x9acd32
|
|
2334
|
+
};
|
|
2335
|
+
|
|
2336
|
+
define(Color, color, {
|
|
2337
|
+
copy(channels) {
|
|
2338
|
+
return Object.assign(new this.constructor, this, channels);
|
|
2339
|
+
},
|
|
2340
|
+
displayable() {
|
|
2341
|
+
return this.rgb().displayable();
|
|
2342
|
+
},
|
|
2343
|
+
hex: color_formatHex, // Deprecated! Use color.formatHex.
|
|
2344
|
+
formatHex: color_formatHex,
|
|
2345
|
+
formatHex8: color_formatHex8,
|
|
2346
|
+
formatHsl: color_formatHsl,
|
|
2347
|
+
formatRgb: color_formatRgb,
|
|
2348
|
+
toString: color_formatRgb
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
function color_formatHex() {
|
|
2352
|
+
return this.rgb().formatHex();
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
function color_formatHex8() {
|
|
2356
|
+
return this.rgb().formatHex8();
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
function color_formatHsl() {
|
|
2360
|
+
return hslConvert(this).formatHsl();
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
function color_formatRgb() {
|
|
2364
|
+
return this.rgb().formatRgb();
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function color(format) {
|
|
2368
|
+
var m, l;
|
|
2369
|
+
format = (format + "").trim().toLowerCase();
|
|
2370
|
+
return (m = reHex.exec(format)) ? (l = m[1].length, m = parseInt(m[1], 16), l === 6 ? rgbn(m) // #ff0000
|
|
2371
|
+
: l === 3 ? new Rgb((m >> 8 & 0xf) | (m >> 4 & 0xf0), (m >> 4 & 0xf) | (m & 0xf0), ((m & 0xf) << 4) | (m & 0xf), 1) // #f00
|
|
2372
|
+
: l === 8 ? rgba(m >> 24 & 0xff, m >> 16 & 0xff, m >> 8 & 0xff, (m & 0xff) / 0xff) // #ff000000
|
|
2373
|
+
: l === 4 ? rgba((m >> 12 & 0xf) | (m >> 8 & 0xf0), (m >> 8 & 0xf) | (m >> 4 & 0xf0), (m >> 4 & 0xf) | (m & 0xf0), (((m & 0xf) << 4) | (m & 0xf)) / 0xff) // #f000
|
|
2374
|
+
: null) // invalid hex
|
|
2375
|
+
: (m = reRgbInteger.exec(format)) ? new Rgb(m[1], m[2], m[3], 1) // rgb(255, 0, 0)
|
|
2376
|
+
: (m = reRgbPercent.exec(format)) ? new Rgb(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, 1) // rgb(100%, 0%, 0%)
|
|
2377
|
+
: (m = reRgbaInteger.exec(format)) ? rgba(m[1], m[2], m[3], m[4]) // rgba(255, 0, 0, 1)
|
|
2378
|
+
: (m = reRgbaPercent.exec(format)) ? rgba(m[1] * 255 / 100, m[2] * 255 / 100, m[3] * 255 / 100, m[4]) // rgb(100%, 0%, 0%, 1)
|
|
2379
|
+
: (m = reHslPercent.exec(format)) ? hsla(m[1], m[2] / 100, m[3] / 100, 1) // hsl(120, 50%, 50%)
|
|
2380
|
+
: (m = reHslaPercent.exec(format)) ? hsla(m[1], m[2] / 100, m[3] / 100, m[4]) // hsla(120, 50%, 50%, 1)
|
|
2381
|
+
: named.hasOwnProperty(format) ? rgbn(named[format]) // eslint-disable-line no-prototype-builtins
|
|
2382
|
+
: format === "transparent" ? new Rgb(NaN, NaN, NaN, 0)
|
|
2383
|
+
: null;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function rgbn(n) {
|
|
2387
|
+
return new Rgb(n >> 16 & 0xff, n >> 8 & 0xff, n & 0xff, 1);
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function rgba(r, g, b, a) {
|
|
2391
|
+
if (a <= 0) r = g = b = NaN;
|
|
2392
|
+
return new Rgb(r, g, b, a);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function rgbConvert(o) {
|
|
2396
|
+
if (!(o instanceof Color)) o = color(o);
|
|
2397
|
+
if (!o) return new Rgb;
|
|
2398
|
+
o = o.rgb();
|
|
2399
|
+
return new Rgb(o.r, o.g, o.b, o.opacity);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function rgb$1(r, g, b, opacity) {
|
|
2403
|
+
return arguments.length === 1 ? rgbConvert(r) : new Rgb(r, g, b, opacity == null ? 1 : opacity);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function Rgb(r, g, b, opacity) {
|
|
2407
|
+
this.r = +r;
|
|
2408
|
+
this.g = +g;
|
|
2409
|
+
this.b = +b;
|
|
2410
|
+
this.opacity = +opacity;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
define(Rgb, rgb$1, extend(Color, {
|
|
2414
|
+
brighter(k) {
|
|
2415
|
+
k = k == null ? brighter : Math.pow(brighter, k);
|
|
2416
|
+
return new Rgb(this.r * k, this.g * k, this.b * k, this.opacity);
|
|
2417
|
+
},
|
|
2418
|
+
darker(k) {
|
|
2419
|
+
k = k == null ? darker : Math.pow(darker, k);
|
|
2420
|
+
return new Rgb(this.r * k, this.g * k, this.b * k, this.opacity);
|
|
2421
|
+
},
|
|
2422
|
+
rgb() {
|
|
2423
|
+
return this;
|
|
2424
|
+
},
|
|
2425
|
+
clamp() {
|
|
2426
|
+
return new Rgb(clampi(this.r), clampi(this.g), clampi(this.b), clampa(this.opacity));
|
|
2427
|
+
},
|
|
2428
|
+
displayable() {
|
|
2429
|
+
return (-0.5 <= this.r && this.r < 255.5)
|
|
2430
|
+
&& (-0.5 <= this.g && this.g < 255.5)
|
|
2431
|
+
&& (-0.5 <= this.b && this.b < 255.5)
|
|
2432
|
+
&& (0 <= this.opacity && this.opacity <= 1);
|
|
2433
|
+
},
|
|
2434
|
+
hex: rgb_formatHex, // Deprecated! Use color.formatHex.
|
|
2435
|
+
formatHex: rgb_formatHex,
|
|
2436
|
+
formatHex8: rgb_formatHex8,
|
|
2437
|
+
formatRgb: rgb_formatRgb,
|
|
2438
|
+
toString: rgb_formatRgb
|
|
2439
|
+
}));
|
|
2440
|
+
|
|
2441
|
+
function rgb_formatHex() {
|
|
2442
|
+
return `#${hex(this.r)}${hex(this.g)}${hex(this.b)}`;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function rgb_formatHex8() {
|
|
2446
|
+
return `#${hex(this.r)}${hex(this.g)}${hex(this.b)}${hex((isNaN(this.opacity) ? 1 : this.opacity) * 255)}`;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function rgb_formatRgb() {
|
|
2450
|
+
const a = clampa(this.opacity);
|
|
2451
|
+
return `${a === 1 ? "rgb(" : "rgba("}${clampi(this.r)}, ${clampi(this.g)}, ${clampi(this.b)}${a === 1 ? ")" : `, ${a})`}`;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function clampa(opacity) {
|
|
2455
|
+
return isNaN(opacity) ? 1 : Math.max(0, Math.min(1, opacity));
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function clampi(value) {
|
|
2459
|
+
return Math.max(0, Math.min(255, Math.round(value) || 0));
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
function hex(value) {
|
|
2463
|
+
value = clampi(value);
|
|
2464
|
+
return (value < 16 ? "0" : "") + value.toString(16);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function hsla(h, s, l, a) {
|
|
2468
|
+
if (a <= 0) h = s = l = NaN;
|
|
2469
|
+
else if (l <= 0 || l >= 1) h = s = NaN;
|
|
2470
|
+
else if (s <= 0) h = NaN;
|
|
2471
|
+
return new Hsl(h, s, l, a);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
function hslConvert(o) {
|
|
2475
|
+
if (o instanceof Hsl) return new Hsl(o.h, o.s, o.l, o.opacity);
|
|
2476
|
+
if (!(o instanceof Color)) o = color(o);
|
|
2477
|
+
if (!o) return new Hsl;
|
|
2478
|
+
if (o instanceof Hsl) return o;
|
|
2479
|
+
o = o.rgb();
|
|
2480
|
+
var r = o.r / 255,
|
|
2481
|
+
g = o.g / 255,
|
|
2482
|
+
b = o.b / 255,
|
|
2483
|
+
min = Math.min(r, g, b),
|
|
2484
|
+
max = Math.max(r, g, b),
|
|
2485
|
+
h = NaN,
|
|
2486
|
+
s = max - min,
|
|
2487
|
+
l = (max + min) / 2;
|
|
2488
|
+
if (s) {
|
|
2489
|
+
if (r === max) h = (g - b) / s + (g < b) * 6;
|
|
2490
|
+
else if (g === max) h = (b - r) / s + 2;
|
|
2491
|
+
else h = (r - g) / s + 4;
|
|
2492
|
+
s /= l < 0.5 ? max + min : 2 - max - min;
|
|
2493
|
+
h *= 60;
|
|
2494
|
+
} else {
|
|
2495
|
+
s = l > 0 && l < 1 ? 0 : h;
|
|
2496
|
+
}
|
|
2497
|
+
return new Hsl(h, s, l, o.opacity);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
function hsl(h, s, l, opacity) {
|
|
2501
|
+
return arguments.length === 1 ? hslConvert(h) : new Hsl(h, s, l, opacity == null ? 1 : opacity);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function Hsl(h, s, l, opacity) {
|
|
2505
|
+
this.h = +h;
|
|
2506
|
+
this.s = +s;
|
|
2507
|
+
this.l = +l;
|
|
2508
|
+
this.opacity = +opacity;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
define(Hsl, hsl, extend(Color, {
|
|
2512
|
+
brighter(k) {
|
|
2513
|
+
k = k == null ? brighter : Math.pow(brighter, k);
|
|
2514
|
+
return new Hsl(this.h, this.s, this.l * k, this.opacity);
|
|
2515
|
+
},
|
|
2516
|
+
darker(k) {
|
|
2517
|
+
k = k == null ? darker : Math.pow(darker, k);
|
|
2518
|
+
return new Hsl(this.h, this.s, this.l * k, this.opacity);
|
|
2519
|
+
},
|
|
2520
|
+
rgb() {
|
|
2521
|
+
var h = this.h % 360 + (this.h < 0) * 360,
|
|
2522
|
+
s = isNaN(h) || isNaN(this.s) ? 0 : this.s,
|
|
2523
|
+
l = this.l,
|
|
2524
|
+
m2 = l + (l < 0.5 ? l : 1 - l) * s,
|
|
2525
|
+
m1 = 2 * l - m2;
|
|
2526
|
+
return new Rgb(
|
|
2527
|
+
hsl2rgb(h >= 240 ? h - 240 : h + 120, m1, m2),
|
|
2528
|
+
hsl2rgb(h, m1, m2),
|
|
2529
|
+
hsl2rgb(h < 120 ? h + 240 : h - 120, m1, m2),
|
|
2530
|
+
this.opacity
|
|
2531
|
+
);
|
|
2532
|
+
},
|
|
2533
|
+
clamp() {
|
|
2534
|
+
return new Hsl(clamph(this.h), clampt(this.s), clampt(this.l), clampa(this.opacity));
|
|
2535
|
+
},
|
|
2536
|
+
displayable() {
|
|
2537
|
+
return (0 <= this.s && this.s <= 1 || isNaN(this.s))
|
|
2538
|
+
&& (0 <= this.l && this.l <= 1)
|
|
2539
|
+
&& (0 <= this.opacity && this.opacity <= 1);
|
|
2540
|
+
},
|
|
2541
|
+
formatHsl() {
|
|
2542
|
+
const a = clampa(this.opacity);
|
|
2543
|
+
return `${a === 1 ? "hsl(" : "hsla("}${clamph(this.h)}, ${clampt(this.s) * 100}%, ${clampt(this.l) * 100}%${a === 1 ? ")" : `, ${a})`}`;
|
|
2544
|
+
}
|
|
2545
|
+
}));
|
|
2546
|
+
|
|
2547
|
+
function clamph(value) {
|
|
2548
|
+
value = (value || 0) % 360;
|
|
2549
|
+
return value < 0 ? value + 360 : value;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function clampt(value) {
|
|
2553
|
+
return Math.max(0, Math.min(1, value || 0));
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
/* From FvD 13.37, CSS Color Module Level 3 */
|
|
2557
|
+
function hsl2rgb(h, m1, m2) {
|
|
2558
|
+
return (h < 60 ? m1 + (m2 - m1) * h / 60
|
|
2559
|
+
: h < 180 ? m2
|
|
2560
|
+
: h < 240 ? m1 + (m2 - m1) * (240 - h) / 60
|
|
2561
|
+
: m1) * 255;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
var constant = x => () => x;
|
|
2565
|
+
|
|
2566
|
+
function linear$1(a, d) {
|
|
2567
|
+
return function(t) {
|
|
2568
|
+
return a + t * d;
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
function exponential(a, b, y) {
|
|
2573
|
+
return a = Math.pow(a, y), b = Math.pow(b, y) - a, y = 1 / y, function(t) {
|
|
2574
|
+
return Math.pow(a + t * b, y);
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function gamma(y) {
|
|
2579
|
+
return (y = +y) === 1 ? nogamma : function(a, b) {
|
|
2580
|
+
return b - a ? exponential(a, b, y) : constant(isNaN(a) ? b : a);
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function nogamma(a, b) {
|
|
2585
|
+
var d = b - a;
|
|
2586
|
+
return d ? linear$1(a, d) : constant(isNaN(a) ? b : a);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
var rgb = (function rgbGamma(y) {
|
|
2590
|
+
var color = gamma(y);
|
|
2591
|
+
|
|
2592
|
+
function rgb(start, end) {
|
|
2593
|
+
var r = color((start = rgb$1(start)).r, (end = rgb$1(end)).r),
|
|
2594
|
+
g = color(start.g, end.g),
|
|
2595
|
+
b = color(start.b, end.b),
|
|
2596
|
+
opacity = nogamma(start.opacity, end.opacity);
|
|
2597
|
+
return function(t) {
|
|
2598
|
+
start.r = r(t);
|
|
2599
|
+
start.g = g(t);
|
|
2600
|
+
start.b = b(t);
|
|
2601
|
+
start.opacity = opacity(t);
|
|
2602
|
+
return start + "";
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
rgb.gamma = rgbGamma;
|
|
2607
|
+
|
|
2608
|
+
return rgb;
|
|
2609
|
+
})(1);
|
|
2610
|
+
|
|
2611
|
+
function numberArray(a, b) {
|
|
2612
|
+
if (!b) b = [];
|
|
2613
|
+
var n = a ? Math.min(b.length, a.length) : 0,
|
|
2614
|
+
c = b.slice(),
|
|
2615
|
+
i;
|
|
2616
|
+
return function(t) {
|
|
2617
|
+
for (i = 0; i < n; ++i) c[i] = a[i] * (1 - t) + b[i] * t;
|
|
2618
|
+
return c;
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function isNumberArray(x) {
|
|
2623
|
+
return ArrayBuffer.isView(x) && !(x instanceof DataView);
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function genericArray(a, b) {
|
|
2627
|
+
var nb = b ? b.length : 0,
|
|
2628
|
+
na = a ? Math.min(nb, a.length) : 0,
|
|
2629
|
+
x = new Array(na),
|
|
2630
|
+
c = new Array(nb),
|
|
2631
|
+
i;
|
|
2632
|
+
|
|
2633
|
+
for (i = 0; i < na; ++i) x[i] = interpolate(a[i], b[i]);
|
|
2634
|
+
for (; i < nb; ++i) c[i] = b[i];
|
|
2635
|
+
|
|
2636
|
+
return function(t) {
|
|
2637
|
+
for (i = 0; i < na; ++i) c[i] = x[i](t);
|
|
2638
|
+
return c;
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
function date(a, b) {
|
|
2643
|
+
var d = new Date;
|
|
2644
|
+
return a = +a, b = +b, function(t) {
|
|
2645
|
+
return d.setTime(a * (1 - t) + b * t), d;
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
function interpolateNumber(a, b) {
|
|
2650
|
+
return a = +a, b = +b, function(t) {
|
|
2651
|
+
return a * (1 - t) + b * t;
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function object(a, b) {
|
|
2656
|
+
var i = {},
|
|
2657
|
+
c = {},
|
|
2658
|
+
k;
|
|
2659
|
+
|
|
2660
|
+
if (a === null || typeof a !== "object") a = {};
|
|
2661
|
+
if (b === null || typeof b !== "object") b = {};
|
|
2662
|
+
|
|
2663
|
+
for (k in b) {
|
|
2664
|
+
if (k in a) {
|
|
2665
|
+
i[k] = interpolate(a[k], b[k]);
|
|
2666
|
+
} else {
|
|
2667
|
+
c[k] = b[k];
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
return function(t) {
|
|
2672
|
+
for (k in i) c[k] = i[k](t);
|
|
2673
|
+
return c;
|
|
2674
|
+
};
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
var reA = /[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g,
|
|
2678
|
+
reB = new RegExp(reA.source, "g");
|
|
2679
|
+
|
|
2680
|
+
function zero(b) {
|
|
2681
|
+
return function() {
|
|
2682
|
+
return b;
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
function one(b) {
|
|
2687
|
+
return function(t) {
|
|
2688
|
+
return b(t) + "";
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function string(a, b) {
|
|
2693
|
+
var bi = reA.lastIndex = reB.lastIndex = 0, // scan index for next number in b
|
|
2694
|
+
am, // current match in a
|
|
2695
|
+
bm, // current match in b
|
|
2696
|
+
bs, // string preceding current number in b, if any
|
|
2697
|
+
i = -1, // index in s
|
|
2698
|
+
s = [], // string constants and placeholders
|
|
2699
|
+
q = []; // number interpolators
|
|
2700
|
+
|
|
2701
|
+
// Coerce inputs to strings.
|
|
2702
|
+
a = a + "", b = b + "";
|
|
2703
|
+
|
|
2704
|
+
// Interpolate pairs of numbers in a & b.
|
|
2705
|
+
while ((am = reA.exec(a))
|
|
2706
|
+
&& (bm = reB.exec(b))) {
|
|
2707
|
+
if ((bs = bm.index) > bi) { // a string precedes the next number in b
|
|
2708
|
+
bs = b.slice(bi, bs);
|
|
2709
|
+
if (s[i]) s[i] += bs; // coalesce with previous string
|
|
2710
|
+
else s[++i] = bs;
|
|
2711
|
+
}
|
|
2712
|
+
if ((am = am[0]) === (bm = bm[0])) { // numbers in a & b match
|
|
2713
|
+
if (s[i]) s[i] += bm; // coalesce with previous string
|
|
2714
|
+
else s[++i] = bm;
|
|
2715
|
+
} else { // interpolate non-matching numbers
|
|
2716
|
+
s[++i] = null;
|
|
2717
|
+
q.push({i: i, x: interpolateNumber(am, bm)});
|
|
2718
|
+
}
|
|
2719
|
+
bi = reB.lastIndex;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// Add remains of b.
|
|
2723
|
+
if (bi < b.length) {
|
|
2724
|
+
bs = b.slice(bi);
|
|
2725
|
+
if (s[i]) s[i] += bs; // coalesce with previous string
|
|
2726
|
+
else s[++i] = bs;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// Special optimization for only a single match.
|
|
2730
|
+
// Otherwise, interpolate each of the numbers and rejoin the string.
|
|
2731
|
+
return s.length < 2 ? (q[0]
|
|
2732
|
+
? one(q[0].x)
|
|
2733
|
+
: zero(b))
|
|
2734
|
+
: (b = q.length, function(t) {
|
|
2735
|
+
for (var i = 0, o; i < b; ++i) s[(o = q[i]).i] = o.x(t);
|
|
2736
|
+
return s.join("");
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function interpolate(a, b) {
|
|
2741
|
+
var t = typeof b, c;
|
|
2742
|
+
return b == null || t === "boolean" ? constant(b)
|
|
2743
|
+
: (t === "number" ? interpolateNumber
|
|
2744
|
+
: t === "string" ? ((c = color(b)) ? (b = c, rgb) : string)
|
|
2745
|
+
: b instanceof color ? rgb
|
|
2746
|
+
: b instanceof Date ? date
|
|
2747
|
+
: isNumberArray(b) ? numberArray
|
|
2748
|
+
: Array.isArray(b) ? genericArray
|
|
2749
|
+
: typeof b.valueOf !== "function" && typeof b.toString !== "function" || isNaN(b) ? object
|
|
2750
|
+
: interpolateNumber)(a, b);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function interpolateRound(a, b) {
|
|
2754
|
+
return a = +a, b = +b, function(t) {
|
|
2755
|
+
return Math.round(a * (1 - t) + b * t);
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
function constants(x) {
|
|
2760
|
+
return function() {
|
|
2761
|
+
return x;
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function number(x) {
|
|
2766
|
+
return +x;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
var unit = [0, 1];
|
|
2770
|
+
|
|
2771
|
+
function identity$1(x) {
|
|
2772
|
+
return x;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
function normalize(a, b) {
|
|
2776
|
+
return (b -= (a = +a))
|
|
2777
|
+
? function(x) { return (x - a) / b; }
|
|
2778
|
+
: constants(isNaN(b) ? NaN : 0.5);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
function clamper(a, b) {
|
|
2782
|
+
var t;
|
|
2783
|
+
if (a > b) t = a, a = b, b = t;
|
|
2784
|
+
return function(x) { return Math.max(a, Math.min(b, x)); };
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// normalize(a, b)(x) takes a domain value x in [a,b] and returns the corresponding parameter t in [0,1].
|
|
2788
|
+
// interpolate(a, b)(t) takes a parameter t in [0,1] and returns the corresponding range value x in [a,b].
|
|
2789
|
+
function bimap(domain, range, interpolate) {
|
|
2790
|
+
var d0 = domain[0], d1 = domain[1], r0 = range[0], r1 = range[1];
|
|
2791
|
+
if (d1 < d0) d0 = normalize(d1, d0), r0 = interpolate(r1, r0);
|
|
2792
|
+
else d0 = normalize(d0, d1), r0 = interpolate(r0, r1);
|
|
2793
|
+
return function(x) { return r0(d0(x)); };
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function polymap(domain, range, interpolate) {
|
|
2797
|
+
var j = Math.min(domain.length, range.length) - 1,
|
|
2798
|
+
d = new Array(j),
|
|
2799
|
+
r = new Array(j),
|
|
2800
|
+
i = -1;
|
|
2801
|
+
|
|
2802
|
+
// Reverse descending domains.
|
|
2803
|
+
if (domain[j] < domain[0]) {
|
|
2804
|
+
domain = domain.slice().reverse();
|
|
2805
|
+
range = range.slice().reverse();
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
while (++i < j) {
|
|
2809
|
+
d[i] = normalize(domain[i], domain[i + 1]);
|
|
2810
|
+
r[i] = interpolate(range[i], range[i + 1]);
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
return function(x) {
|
|
2814
|
+
var i = bisectRight(domain, x, 1, j) - 1;
|
|
2815
|
+
return r[i](d[i](x));
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
function copy(source, target) {
|
|
2820
|
+
return target
|
|
2821
|
+
.domain(source.domain())
|
|
2822
|
+
.range(source.range())
|
|
2823
|
+
.interpolate(source.interpolate())
|
|
2824
|
+
.clamp(source.clamp())
|
|
2825
|
+
.unknown(source.unknown());
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
function transformer() {
|
|
2829
|
+
var domain = unit,
|
|
2830
|
+
range = unit,
|
|
2831
|
+
interpolate$1 = interpolate,
|
|
2832
|
+
transform,
|
|
2833
|
+
untransform,
|
|
2834
|
+
unknown,
|
|
2835
|
+
clamp = identity$1,
|
|
2836
|
+
piecewise,
|
|
2837
|
+
output,
|
|
2838
|
+
input;
|
|
2839
|
+
|
|
2840
|
+
function rescale() {
|
|
2841
|
+
var n = Math.min(domain.length, range.length);
|
|
2842
|
+
if (clamp !== identity$1) clamp = clamper(domain[0], domain[n - 1]);
|
|
2843
|
+
piecewise = n > 2 ? polymap : bimap;
|
|
2844
|
+
output = input = null;
|
|
2845
|
+
return scale;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
function scale(x) {
|
|
2849
|
+
return x == null || isNaN(x = +x) ? unknown : (output || (output = piecewise(domain.map(transform), range, interpolate$1)))(transform(clamp(x)));
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
scale.invert = function(y) {
|
|
2853
|
+
return clamp(untransform((input || (input = piecewise(range, domain.map(transform), interpolateNumber)))(y)));
|
|
2854
|
+
};
|
|
2855
|
+
|
|
2856
|
+
scale.domain = function(_) {
|
|
2857
|
+
return arguments.length ? (domain = Array.from(_, number), rescale()) : domain.slice();
|
|
2858
|
+
};
|
|
2859
|
+
|
|
2860
|
+
scale.range = function(_) {
|
|
2861
|
+
return arguments.length ? (range = Array.from(_), rescale()) : range.slice();
|
|
2862
|
+
};
|
|
2863
|
+
|
|
2864
|
+
scale.rangeRound = function(_) {
|
|
2865
|
+
return range = Array.from(_), interpolate$1 = interpolateRound, rescale();
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2868
|
+
scale.clamp = function(_) {
|
|
2869
|
+
return arguments.length ? (clamp = _ ? true : identity$1, rescale()) : clamp !== identity$1;
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
scale.interpolate = function(_) {
|
|
2873
|
+
return arguments.length ? (interpolate$1 = _, rescale()) : interpolate$1;
|
|
2874
|
+
};
|
|
2875
|
+
|
|
2876
|
+
scale.unknown = function(_) {
|
|
2877
|
+
return arguments.length ? (unknown = _, scale) : unknown;
|
|
2878
|
+
};
|
|
2879
|
+
|
|
2880
|
+
return function(t, u) {
|
|
2881
|
+
transform = t, untransform = u;
|
|
2882
|
+
return rescale();
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
function continuous() {
|
|
2887
|
+
return transformer()(identity$1, identity$1);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function formatDecimal(x) {
|
|
2891
|
+
return Math.abs(x = Math.round(x)) >= 1e21
|
|
2892
|
+
? x.toLocaleString("en").replace(/,/g, "")
|
|
2893
|
+
: x.toString(10);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// Computes the decimal coefficient and exponent of the specified number x with
|
|
2897
|
+
// significant digits p, where x is positive and p is in [1, 21] or undefined.
|
|
2898
|
+
// For example, formatDecimalParts(1.23) returns ["123", 0].
|
|
2899
|
+
function formatDecimalParts(x, p) {
|
|
2900
|
+
if (!isFinite(x) || x === 0) return null; // NaN, ±Infinity, ±0
|
|
2901
|
+
var i = (x = p ? x.toExponential(p - 1) : x.toExponential()).indexOf("e"), coefficient = x.slice(0, i);
|
|
2902
|
+
|
|
2903
|
+
// The string returned by toExponential either has the form \d\.\d+e[-+]\d+
|
|
2904
|
+
// (e.g., 1.2e+3) or the form \de[-+]\d+ (e.g., 1e+3).
|
|
2905
|
+
return [
|
|
2906
|
+
coefficient.length > 1 ? coefficient[0] + coefficient.slice(2) : coefficient,
|
|
2907
|
+
+x.slice(i + 1)
|
|
2908
|
+
];
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function exponent(x) {
|
|
2912
|
+
return x = formatDecimalParts(Math.abs(x)), x ? x[1] : NaN;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
function formatGroup(grouping, thousands) {
|
|
2916
|
+
return function(value, width) {
|
|
2917
|
+
var i = value.length,
|
|
2918
|
+
t = [],
|
|
2919
|
+
j = 0,
|
|
2920
|
+
g = grouping[0],
|
|
2921
|
+
length = 0;
|
|
2922
|
+
|
|
2923
|
+
while (i > 0 && g > 0) {
|
|
2924
|
+
if (length + g + 1 > width) g = Math.max(1, width - length);
|
|
2925
|
+
t.push(value.substring(i -= g, i + g));
|
|
2926
|
+
if ((length += g + 1) > width) break;
|
|
2927
|
+
g = grouping[j = (j + 1) % grouping.length];
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
return t.reverse().join(thousands);
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function formatNumerals(numerals) {
|
|
2935
|
+
return function(value) {
|
|
2936
|
+
return value.replace(/[0-9]/g, function(i) {
|
|
2937
|
+
return numerals[+i];
|
|
2938
|
+
});
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
// [[fill]align][sign][symbol][0][width][,][.precision][~][type]
|
|
2943
|
+
var re = /^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;
|
|
2944
|
+
|
|
2945
|
+
function formatSpecifier(specifier) {
|
|
2946
|
+
if (!(match = re.exec(specifier))) throw new Error("invalid format: " + specifier);
|
|
2947
|
+
var match;
|
|
2948
|
+
return new FormatSpecifier({
|
|
2949
|
+
fill: match[1],
|
|
2950
|
+
align: match[2],
|
|
2951
|
+
sign: match[3],
|
|
2952
|
+
symbol: match[4],
|
|
2953
|
+
zero: match[5],
|
|
2954
|
+
width: match[6],
|
|
2955
|
+
comma: match[7],
|
|
2956
|
+
precision: match[8] && match[8].slice(1),
|
|
2957
|
+
trim: match[9],
|
|
2958
|
+
type: match[10]
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
formatSpecifier.prototype = FormatSpecifier.prototype; // instanceof
|
|
2963
|
+
|
|
2964
|
+
function FormatSpecifier(specifier) {
|
|
2965
|
+
this.fill = specifier.fill === undefined ? " " : specifier.fill + "";
|
|
2966
|
+
this.align = specifier.align === undefined ? ">" : specifier.align + "";
|
|
2967
|
+
this.sign = specifier.sign === undefined ? "-" : specifier.sign + "";
|
|
2968
|
+
this.symbol = specifier.symbol === undefined ? "" : specifier.symbol + "";
|
|
2969
|
+
this.zero = !!specifier.zero;
|
|
2970
|
+
this.width = specifier.width === undefined ? undefined : +specifier.width;
|
|
2971
|
+
this.comma = !!specifier.comma;
|
|
2972
|
+
this.precision = specifier.precision === undefined ? undefined : +specifier.precision;
|
|
2973
|
+
this.trim = !!specifier.trim;
|
|
2974
|
+
this.type = specifier.type === undefined ? "" : specifier.type + "";
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
FormatSpecifier.prototype.toString = function() {
|
|
2978
|
+
return this.fill
|
|
2979
|
+
+ this.align
|
|
2980
|
+
+ this.sign
|
|
2981
|
+
+ this.symbol
|
|
2982
|
+
+ (this.zero ? "0" : "")
|
|
2983
|
+
+ (this.width === undefined ? "" : Math.max(1, this.width | 0))
|
|
2984
|
+
+ (this.comma ? "," : "")
|
|
2985
|
+
+ (this.precision === undefined ? "" : "." + Math.max(0, this.precision | 0))
|
|
2986
|
+
+ (this.trim ? "~" : "")
|
|
2987
|
+
+ this.type;
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
// Trims insignificant zeros, e.g., replaces 1.2000k with 1.2k.
|
|
2991
|
+
function formatTrim(s) {
|
|
2992
|
+
out: for (var n = s.length, i = 1, i0 = -1, i1; i < n; ++i) {
|
|
2993
|
+
switch (s[i]) {
|
|
2994
|
+
case ".": i0 = i1 = i; break;
|
|
2995
|
+
case "0": if (i0 === 0) i0 = i; i1 = i; break;
|
|
2996
|
+
default: if (!+s[i]) break out; if (i0 > 0) i0 = 0; break;
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
return i0 > 0 ? s.slice(0, i0) + s.slice(i1 + 1) : s;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
var prefixExponent;
|
|
3003
|
+
|
|
3004
|
+
function formatPrefixAuto(x, p) {
|
|
3005
|
+
var d = formatDecimalParts(x, p);
|
|
3006
|
+
if (!d) return prefixExponent = undefined, x.toPrecision(p);
|
|
3007
|
+
var coefficient = d[0],
|
|
3008
|
+
exponent = d[1],
|
|
3009
|
+
i = exponent - (prefixExponent = Math.max(-8, Math.min(8, Math.floor(exponent / 3))) * 3) + 1,
|
|
3010
|
+
n = coefficient.length;
|
|
3011
|
+
return i === n ? coefficient
|
|
3012
|
+
: i > n ? coefficient + new Array(i - n + 1).join("0")
|
|
3013
|
+
: i > 0 ? coefficient.slice(0, i) + "." + coefficient.slice(i)
|
|
3014
|
+
: "0." + new Array(1 - i).join("0") + formatDecimalParts(x, Math.max(0, p + i - 1))[0]; // less than 1y!
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
function formatRounded(x, p) {
|
|
3018
|
+
var d = formatDecimalParts(x, p);
|
|
3019
|
+
if (!d) return x + "";
|
|
3020
|
+
var coefficient = d[0],
|
|
3021
|
+
exponent = d[1];
|
|
3022
|
+
return exponent < 0 ? "0." + new Array(-exponent).join("0") + coefficient
|
|
3023
|
+
: coefficient.length > exponent + 1 ? coefficient.slice(0, exponent + 1) + "." + coefficient.slice(exponent + 1)
|
|
3024
|
+
: coefficient + new Array(exponent - coefficient.length + 2).join("0");
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
var formatTypes = {
|
|
3028
|
+
"%": (x, p) => (x * 100).toFixed(p),
|
|
3029
|
+
"b": (x) => Math.round(x).toString(2),
|
|
3030
|
+
"c": (x) => x + "",
|
|
3031
|
+
"d": formatDecimal,
|
|
3032
|
+
"e": (x, p) => x.toExponential(p),
|
|
3033
|
+
"f": (x, p) => x.toFixed(p),
|
|
3034
|
+
"g": (x, p) => x.toPrecision(p),
|
|
3035
|
+
"o": (x) => Math.round(x).toString(8),
|
|
3036
|
+
"p": (x, p) => formatRounded(x * 100, p),
|
|
3037
|
+
"r": formatRounded,
|
|
3038
|
+
"s": formatPrefixAuto,
|
|
3039
|
+
"X": (x) => Math.round(x).toString(16).toUpperCase(),
|
|
3040
|
+
"x": (x) => Math.round(x).toString(16)
|
|
3041
|
+
};
|
|
3042
|
+
|
|
3043
|
+
function identity(x) {
|
|
3044
|
+
return x;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
var map = Array.prototype.map,
|
|
3048
|
+
prefixes = ["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];
|
|
3049
|
+
|
|
3050
|
+
function formatLocale(locale) {
|
|
3051
|
+
var group = locale.grouping === undefined || locale.thousands === undefined ? identity : formatGroup(map.call(locale.grouping, Number), locale.thousands + ""),
|
|
3052
|
+
currencyPrefix = locale.currency === undefined ? "" : locale.currency[0] + "",
|
|
3053
|
+
currencySuffix = locale.currency === undefined ? "" : locale.currency[1] + "",
|
|
3054
|
+
decimal = locale.decimal === undefined ? "." : locale.decimal + "",
|
|
3055
|
+
numerals = locale.numerals === undefined ? identity : formatNumerals(map.call(locale.numerals, String)),
|
|
3056
|
+
percent = locale.percent === undefined ? "%" : locale.percent + "",
|
|
3057
|
+
minus = locale.minus === undefined ? "−" : locale.minus + "",
|
|
3058
|
+
nan = locale.nan === undefined ? "NaN" : locale.nan + "";
|
|
3059
|
+
|
|
3060
|
+
function newFormat(specifier, options) {
|
|
3061
|
+
specifier = formatSpecifier(specifier);
|
|
3062
|
+
|
|
3063
|
+
var fill = specifier.fill,
|
|
3064
|
+
align = specifier.align,
|
|
3065
|
+
sign = specifier.sign,
|
|
3066
|
+
symbol = specifier.symbol,
|
|
3067
|
+
zero = specifier.zero,
|
|
3068
|
+
width = specifier.width,
|
|
3069
|
+
comma = specifier.comma,
|
|
3070
|
+
precision = specifier.precision,
|
|
3071
|
+
trim = specifier.trim,
|
|
3072
|
+
type = specifier.type;
|
|
3073
|
+
|
|
3074
|
+
// The "n" type is an alias for ",g".
|
|
3075
|
+
if (type === "n") comma = true, type = "g";
|
|
3076
|
+
|
|
3077
|
+
// The "" type, and any invalid type, is an alias for ".12~g".
|
|
3078
|
+
else if (!formatTypes[type]) precision === undefined && (precision = 12), trim = true, type = "g";
|
|
3079
|
+
|
|
3080
|
+
// If zero fill is specified, padding goes after sign and before digits.
|
|
3081
|
+
if (zero || (fill === "0" && align === "=")) zero = true, fill = "0", align = "=";
|
|
3082
|
+
|
|
3083
|
+
// Compute the prefix and suffix.
|
|
3084
|
+
// For SI-prefix, the suffix is lazily computed.
|
|
3085
|
+
var prefix = (options && options.prefix !== undefined ? options.prefix : "") + (symbol === "$" ? currencyPrefix : symbol === "#" && /[boxX]/.test(type) ? "0" + type.toLowerCase() : ""),
|
|
3086
|
+
suffix = (symbol === "$" ? currencySuffix : /[%p]/.test(type) ? percent : "") + (options && options.suffix !== undefined ? options.suffix : "");
|
|
3087
|
+
|
|
3088
|
+
// What format function should we use?
|
|
3089
|
+
// Is this an integer type?
|
|
3090
|
+
// Can this type generate exponential notation?
|
|
3091
|
+
var formatType = formatTypes[type],
|
|
3092
|
+
maybeSuffix = /[defgprs%]/.test(type);
|
|
3093
|
+
|
|
3094
|
+
// Set the default precision if not specified,
|
|
3095
|
+
// or clamp the specified precision to the supported range.
|
|
3096
|
+
// For significant precision, it must be in [1, 21].
|
|
3097
|
+
// For fixed precision, it must be in [0, 20].
|
|
3098
|
+
precision = precision === undefined ? 6
|
|
3099
|
+
: /[gprs]/.test(type) ? Math.max(1, Math.min(21, precision))
|
|
3100
|
+
: Math.max(0, Math.min(20, precision));
|
|
3101
|
+
|
|
3102
|
+
function format(value) {
|
|
3103
|
+
var valuePrefix = prefix,
|
|
3104
|
+
valueSuffix = suffix,
|
|
3105
|
+
i, n, c;
|
|
3106
|
+
|
|
3107
|
+
if (type === "c") {
|
|
3108
|
+
valueSuffix = formatType(value) + valueSuffix;
|
|
3109
|
+
value = "";
|
|
3110
|
+
} else {
|
|
3111
|
+
value = +value;
|
|
3112
|
+
|
|
3113
|
+
// Determine the sign. -0 is not less than 0, but 1 / -0 is!
|
|
3114
|
+
var valueNegative = value < 0 || 1 / value < 0;
|
|
3115
|
+
|
|
3116
|
+
// Perform the initial formatting.
|
|
3117
|
+
value = isNaN(value) ? nan : formatType(Math.abs(value), precision);
|
|
3118
|
+
|
|
3119
|
+
// Trim insignificant zeros.
|
|
3120
|
+
if (trim) value = formatTrim(value);
|
|
3121
|
+
|
|
3122
|
+
// If a negative value rounds to zero after formatting, and no explicit positive sign is requested, hide the sign.
|
|
3123
|
+
if (valueNegative && +value === 0 && sign !== "+") valueNegative = false;
|
|
3124
|
+
|
|
3125
|
+
// Compute the prefix and suffix.
|
|
3126
|
+
valuePrefix = (valueNegative ? (sign === "(" ? sign : minus) : sign === "-" || sign === "(" ? "" : sign) + valuePrefix;
|
|
3127
|
+
valueSuffix = (type === "s" && !isNaN(value) && prefixExponent !== undefined ? prefixes[8 + prefixExponent / 3] : "") + valueSuffix + (valueNegative && sign === "(" ? ")" : "");
|
|
3128
|
+
|
|
3129
|
+
// Break the formatted value into the integer “value” part that can be
|
|
3130
|
+
// grouped, and fractional or exponential “suffix” part that is not.
|
|
3131
|
+
if (maybeSuffix) {
|
|
3132
|
+
i = -1, n = value.length;
|
|
3133
|
+
while (++i < n) {
|
|
3134
|
+
if (c = value.charCodeAt(i), 48 > c || c > 57) {
|
|
3135
|
+
valueSuffix = (c === 46 ? decimal + value.slice(i + 1) : value.slice(i)) + valueSuffix;
|
|
3136
|
+
value = value.slice(0, i);
|
|
3137
|
+
break;
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
// If the fill character is not "0", grouping is applied before padding.
|
|
3144
|
+
if (comma && !zero) value = group(value, Infinity);
|
|
3145
|
+
|
|
3146
|
+
// Compute the padding.
|
|
3147
|
+
var length = valuePrefix.length + value.length + valueSuffix.length,
|
|
3148
|
+
padding = length < width ? new Array(width - length + 1).join(fill) : "";
|
|
3149
|
+
|
|
3150
|
+
// If the fill character is "0", grouping is applied after padding.
|
|
3151
|
+
if (comma && zero) value = group(padding + value, padding.length ? width - valueSuffix.length : Infinity), padding = "";
|
|
3152
|
+
|
|
3153
|
+
// Reconstruct the final output based on the desired alignment.
|
|
3154
|
+
switch (align) {
|
|
3155
|
+
case "<": value = valuePrefix + value + valueSuffix + padding; break;
|
|
3156
|
+
case "=": value = valuePrefix + padding + value + valueSuffix; break;
|
|
3157
|
+
case "^": value = padding.slice(0, length = padding.length >> 1) + valuePrefix + value + valueSuffix + padding.slice(length); break;
|
|
3158
|
+
default: value = padding + valuePrefix + value + valueSuffix; break;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
return numerals(value);
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
format.toString = function() {
|
|
3165
|
+
return specifier + "";
|
|
3166
|
+
};
|
|
3167
|
+
|
|
3168
|
+
return format;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
function formatPrefix(specifier, value) {
|
|
3172
|
+
var e = Math.max(-8, Math.min(8, Math.floor(exponent(value) / 3))) * 3,
|
|
3173
|
+
k = Math.pow(10, -e),
|
|
3174
|
+
f = newFormat((specifier = formatSpecifier(specifier), specifier.type = "f", specifier), {suffix: prefixes[8 + e / 3]});
|
|
3175
|
+
return function(value) {
|
|
3176
|
+
return f(k * value);
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
return {
|
|
3181
|
+
format: newFormat,
|
|
3182
|
+
formatPrefix: formatPrefix
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
var locale;
|
|
3187
|
+
var format;
|
|
3188
|
+
var formatPrefix;
|
|
3189
|
+
|
|
3190
|
+
defaultLocale({
|
|
3191
|
+
thousands: ",",
|
|
3192
|
+
grouping: [3],
|
|
3193
|
+
currency: ["$", ""]
|
|
3194
|
+
});
|
|
3195
|
+
|
|
3196
|
+
function defaultLocale(definition) {
|
|
3197
|
+
locale = formatLocale(definition);
|
|
3198
|
+
format = locale.format;
|
|
3199
|
+
formatPrefix = locale.formatPrefix;
|
|
3200
|
+
return locale;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
function precisionFixed(step) {
|
|
3204
|
+
return Math.max(0, -exponent(Math.abs(step)));
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function precisionPrefix(step, value) {
|
|
3208
|
+
return Math.max(0, Math.max(-8, Math.min(8, Math.floor(exponent(value) / 3))) * 3 - exponent(Math.abs(step)));
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
function precisionRound(step, max) {
|
|
3212
|
+
step = Math.abs(step), max = Math.abs(max) - step;
|
|
3213
|
+
return Math.max(0, exponent(max) - exponent(step)) + 1;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
function tickFormat(start, stop, count, specifier) {
|
|
3217
|
+
var step = tickStep(start, stop, count),
|
|
3218
|
+
precision;
|
|
3219
|
+
specifier = formatSpecifier(specifier == null ? ",f" : specifier);
|
|
3220
|
+
switch (specifier.type) {
|
|
3221
|
+
case "s": {
|
|
3222
|
+
var value = Math.max(Math.abs(start), Math.abs(stop));
|
|
3223
|
+
if (specifier.precision == null && !isNaN(precision = precisionPrefix(step, value))) specifier.precision = precision;
|
|
3224
|
+
return formatPrefix(specifier, value);
|
|
3225
|
+
}
|
|
3226
|
+
case "":
|
|
3227
|
+
case "e":
|
|
3228
|
+
case "g":
|
|
3229
|
+
case "p":
|
|
3230
|
+
case "r": {
|
|
3231
|
+
if (specifier.precision == null && !isNaN(precision = precisionRound(step, Math.max(Math.abs(start), Math.abs(stop))))) specifier.precision = precision - (specifier.type === "e");
|
|
3232
|
+
break;
|
|
3233
|
+
}
|
|
3234
|
+
case "f":
|
|
3235
|
+
case "%": {
|
|
3236
|
+
if (specifier.precision == null && !isNaN(precision = precisionFixed(step))) specifier.precision = precision - (specifier.type === "%") * 2;
|
|
3237
|
+
break;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
return format(specifier);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
function linearish(scale) {
|
|
3244
|
+
var domain = scale.domain;
|
|
3245
|
+
|
|
3246
|
+
scale.ticks = function(count) {
|
|
3247
|
+
var d = domain();
|
|
3248
|
+
return ticks(d[0], d[d.length - 1], count == null ? 10 : count);
|
|
3249
|
+
};
|
|
3250
|
+
|
|
3251
|
+
scale.tickFormat = function(count, specifier) {
|
|
3252
|
+
var d = domain();
|
|
3253
|
+
return tickFormat(d[0], d[d.length - 1], count == null ? 10 : count, specifier);
|
|
3254
|
+
};
|
|
3255
|
+
|
|
3256
|
+
scale.nice = function(count) {
|
|
3257
|
+
if (count == null) count = 10;
|
|
3258
|
+
|
|
3259
|
+
var d = domain();
|
|
3260
|
+
var i0 = 0;
|
|
3261
|
+
var i1 = d.length - 1;
|
|
3262
|
+
var start = d[i0];
|
|
3263
|
+
var stop = d[i1];
|
|
3264
|
+
var prestep;
|
|
3265
|
+
var step;
|
|
3266
|
+
var maxIter = 10;
|
|
3267
|
+
|
|
3268
|
+
if (stop < start) {
|
|
3269
|
+
step = start, start = stop, stop = step;
|
|
3270
|
+
step = i0, i0 = i1, i1 = step;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
while (maxIter-- > 0) {
|
|
3274
|
+
step = tickIncrement(start, stop, count);
|
|
3275
|
+
if (step === prestep) {
|
|
3276
|
+
d[i0] = start;
|
|
3277
|
+
d[i1] = stop;
|
|
3278
|
+
return domain(d);
|
|
3279
|
+
} else if (step > 0) {
|
|
3280
|
+
start = Math.floor(start / step) * step;
|
|
3281
|
+
stop = Math.ceil(stop / step) * step;
|
|
3282
|
+
} else if (step < 0) {
|
|
3283
|
+
start = Math.ceil(start * step) / step;
|
|
3284
|
+
stop = Math.floor(stop * step) / step;
|
|
3285
|
+
} else {
|
|
3286
|
+
break;
|
|
3287
|
+
}
|
|
3288
|
+
prestep = step;
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
return scale;
|
|
3292
|
+
};
|
|
3293
|
+
|
|
3294
|
+
return scale;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
function linear() {
|
|
3298
|
+
var scale = continuous();
|
|
3299
|
+
|
|
3300
|
+
scale.copy = function() {
|
|
3301
|
+
return copy(scale, linear());
|
|
3302
|
+
};
|
|
3303
|
+
|
|
3304
|
+
initRange.apply(scale, arguments);
|
|
3305
|
+
|
|
3306
|
+
return linearish(scale);
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
function transformPow(exponent) {
|
|
3310
|
+
return function(x) {
|
|
3311
|
+
return x < 0 ? -Math.pow(-x, exponent) : Math.pow(x, exponent);
|
|
3312
|
+
};
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
function transformSqrt(x) {
|
|
3316
|
+
return x < 0 ? -Math.sqrt(-x) : Math.sqrt(x);
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
function transformSquare(x) {
|
|
3320
|
+
return x < 0 ? -x * x : x * x;
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
function powish(transform) {
|
|
3324
|
+
var scale = transform(identity$1, identity$1),
|
|
3325
|
+
exponent = 1;
|
|
3326
|
+
|
|
3327
|
+
function rescale() {
|
|
3328
|
+
return exponent === 1 ? transform(identity$1, identity$1)
|
|
3329
|
+
: exponent === 0.5 ? transform(transformSqrt, transformSquare)
|
|
3330
|
+
: transform(transformPow(exponent), transformPow(1 / exponent));
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
scale.exponent = function(_) {
|
|
3334
|
+
return arguments.length ? (exponent = +_, rescale()) : exponent;
|
|
3335
|
+
};
|
|
3336
|
+
|
|
3337
|
+
return linearish(scale);
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
function pow() {
|
|
3341
|
+
var scale = powish(transformer());
|
|
3342
|
+
|
|
3343
|
+
scale.copy = function() {
|
|
3344
|
+
return copy(scale, pow()).exponent(scale.exponent());
|
|
3345
|
+
};
|
|
3346
|
+
|
|
3347
|
+
initRange.apply(scale, arguments);
|
|
3348
|
+
|
|
3349
|
+
return scale;
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
function sqrt() {
|
|
3353
|
+
return pow.apply(null, arguments).exponent(0.5);
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
const BAR_SCALES = ["log", "sqrt", "linear"];
|
|
3357
|
+
|
|
3358
|
+
/**
|
|
3359
|
+
* Bar-length scale for the count histogram: mass -> pixel length.
|
|
3360
|
+
*
|
|
3361
|
+
* The default is LOG, and that is not a stylistic choice. Real hierarchies of
|
|
3362
|
+
* this kind have pathological count distributions — a photo library where one
|
|
3363
|
+
* "Unknown date" bucket holds 113,656 of 114,125 photos is a true story. Under a
|
|
3364
|
+
* linear scale every other bar is the 4px minimum; under sqrt, a busy 300-photo
|
|
3365
|
+
* day still renders at 5% of the track. Both turn the histogram into a row of
|
|
3366
|
+
* identical stubs that encode nothing. `log1p(300)/log1p(113656) = 49%` — the
|
|
3367
|
+
* silhouette comes back.
|
|
3368
|
+
*
|
|
3369
|
+
* Use `linear` when the counts are known to be well-behaved and you want the
|
|
3370
|
+
* bars to be honestly proportional.
|
|
3371
|
+
*
|
|
3372
|
+
* @param {number} maxCount
|
|
3373
|
+
* @param {number} maxLenPx
|
|
3374
|
+
* @param {{minLenPx?: number, type?: "log"|"sqrt"|"linear"}} [opts]
|
|
3375
|
+
* @returns {(count: number) => number}
|
|
3376
|
+
*/
|
|
3377
|
+
function makeBarScale(maxCount, maxLenPx, opts = {}) {
|
|
3378
|
+
const { minLenPx = 4, type = "log" } = opts;
|
|
3379
|
+
const hi = Math.max(1, maxCount);
|
|
3380
|
+
|
|
3381
|
+
if (type === "log") {
|
|
3382
|
+
// log1p, not scaleLog: the domain starts at 0 (an empty group is legal) and
|
|
3383
|
+
// log(0) is -Infinity.
|
|
3384
|
+
const denom = Math.log1p(hi);
|
|
3385
|
+
return (count) => {
|
|
3386
|
+
const c = Math.max(0, Math.min(count, hi));
|
|
3387
|
+
const t = denom > 0 ? Math.log1p(c) / denom : 0;
|
|
3388
|
+
return minLenPx + (maxLenPx - minLenPx) * t;
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
const scale = type === "linear" ? linear() : sqrt();
|
|
3393
|
+
return scale.domain([0, hi]).range([minLenPx, maxLenPx]).clamp(true);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
/**
|
|
3397
|
+
* The layout: one signature, three named strategies, ONE row contract.
|
|
3398
|
+
*
|
|
3399
|
+
* The strategies are not three algorithms — they are three cells of a 2x2 over
|
|
3400
|
+
* two independent stages, `selection` x `positioning`:
|
|
3401
|
+
*
|
|
3402
|
+
* | uniform positions | fisheye positions
|
|
3403
|
+
* -------------+-------------------+-------------------
|
|
3404
|
+
* all leaves | "uniform" | "fisheye"
|
|
3405
|
+
* DOI-selected | "doi" | "hybrid"
|
|
3406
|
+
*
|
|
3407
|
+
* Everything downstream (bands, renderers, tests) is shared, so adding a fourth
|
|
3408
|
+
* strategy means adding a selector or a positioner, never a new pipeline.
|
|
3409
|
+
*
|
|
3410
|
+
* Note what this dissolves: AutoGallery's old `positioning: "rank" | "proportional"`
|
|
3411
|
+
* knob existed only because decimation squashed the near-focus leaves into a
|
|
3412
|
+
* sub-pixel sliver. Here, "fisheye" never decimates (so rank IS proportional),
|
|
3413
|
+
* and "hybrid" magnifies over DOI-selected slots (so rank is simply correct).
|
|
3414
|
+
* The knob has no reason to exist.
|
|
3415
|
+
*/
|
|
3416
|
+
|
|
3417
|
+
|
|
3418
|
+
const LAYOUTS = ["fisheye", "doi", "hybrid", "uniform"];
|
|
3419
|
+
|
|
3420
|
+
const LAYOUT_DEFAULTS = {
|
|
3421
|
+
/** Fisheye strength. Higher = gentler. (Ported knob; same meaning as before.) */
|
|
3422
|
+
distortion: 4,
|
|
3423
|
+
/** Top/bottom inset, px. */
|
|
3424
|
+
pad: 6,
|
|
3425
|
+
/** Target minimum row height — sets the DOI row budget. */
|
|
3426
|
+
minRowPx: 16,
|
|
3427
|
+
/** What a band's HEIGHT encodes.
|
|
3428
|
+
* "slots" — every visible group gets equal room, so height reads as INTEREST.
|
|
3429
|
+
* "count" — height is proportional to photo mass, so a 40k-photo year visibly
|
|
3430
|
+
* dwarfs a 400-photo one. A true value-encoded partition diagram. */
|
|
3431
|
+
sizeBy: "slots",
|
|
3432
|
+
/** Under `sizeBy: "count"`, the fraction of the column split EQUALLY before the
|
|
3433
|
+
* rest is divided by mass. Without it, a zero-count group gets a zero-height
|
|
3434
|
+
* band: invisible and impossible to click. 1 collapses back to "slots", so the
|
|
3435
|
+
* two modes are the ends of one continuum. */
|
|
3436
|
+
sizeFloor: 0.25,
|
|
3437
|
+
/** DOI: penalty for sitting at a different level than the focus. */
|
|
3438
|
+
apiWeight: 0.5,
|
|
3439
|
+
/** DOI: how fast interest falls off with distance from the focus. */
|
|
3440
|
+
distanceWeight: 1,
|
|
3441
|
+
/** DOI: 0 = distance measured purely along the leaf axis (a clean readable
|
|
3442
|
+
* window, blind to structure); 1 = purely tree distance (keeps the focus's
|
|
3443
|
+
* siblings and cousins, but scatters survivors down the column). */
|
|
3444
|
+
treeWeight: 0.6,
|
|
3445
|
+
/** DOI: how much photo mass earns a row. A weak tiebreaker by design — turn it
|
|
3446
|
+
* up and the lens becomes a popularity chart instead of a "where am I". */
|
|
3447
|
+
countWeight: 0.25,
|
|
3448
|
+
};
|
|
3449
|
+
|
|
3450
|
+
/**
|
|
3451
|
+
* @param {any} root a decorated hierarchy (see hierarchy.js `normalize`)
|
|
3452
|
+
* @param {object} options
|
|
3453
|
+
* @param {number} options.height
|
|
3454
|
+
* @param {"fisheye"|"doi"|"hybrid"|"uniform"} [options.layout="fisheye"]
|
|
3455
|
+
* @param {{leaf?: number, px?: number}} [options.focus] focus by leaf index, or
|
|
3456
|
+
* pinned to a cursor pixel. Pixel wins when both are given — that is what keeps
|
|
3457
|
+
* the magnified row exactly under the pointer.
|
|
3458
|
+
* @returns {{rows: any[], maxCount: number, focusLeaf: number, focusPx: number,
|
|
3459
|
+
* slots: number, leaves: number}}
|
|
3460
|
+
*/
|
|
3461
|
+
function layout(root, options = {}) {
|
|
3462
|
+
const o = { ...LAYOUT_DEFAULTS, ...options };
|
|
3463
|
+
const height = o.height ?? 0;
|
|
3464
|
+
const leaves = leavesOf(root);
|
|
3465
|
+
const n = leaves.length;
|
|
3466
|
+
|
|
3467
|
+
const min = o.pad;
|
|
3468
|
+
const max = height - o.pad;
|
|
3469
|
+
if (!n || max <= min) {
|
|
3470
|
+
return {
|
|
3471
|
+
rows: [],
|
|
3472
|
+
maxCount: 0,
|
|
3473
|
+
focusLeaf: 0,
|
|
3474
|
+
focusPx: min,
|
|
3475
|
+
slots: 0,
|
|
3476
|
+
leaves: n,
|
|
3477
|
+
};
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
const strategy = LAYOUTS.includes(o.layout) ? o.layout : "fisheye";
|
|
3481
|
+
const usesDOI = strategy === "doi" || strategy === "hybrid";
|
|
3482
|
+
const usesFisheye = strategy === "fisheye" || strategy === "hybrid";
|
|
3483
|
+
|
|
3484
|
+
const sizeBy = SIZE_BY.includes(o.sizeBy) ? o.sizeBy : "slots";
|
|
3485
|
+
const sizing = { min, max, sizeBy, sizeFloor: o.sizeFloor };
|
|
3486
|
+
|
|
3487
|
+
const budget = Math.max(4, Math.floor((max - min) / o.minRowPx));
|
|
3488
|
+
|
|
3489
|
+
// Which leaf is the focus? Under DOI this looks circular — the slot axis exists
|
|
3490
|
+
// only once DOI has run, DOI needs a focus leaf, and the focus leaf is whatever
|
|
3491
|
+
// sits under the cursor — but it isn't, as long as the caller says which ROW it
|
|
3492
|
+
// is pointing at. The widget reads that off the frame it just drew (only it can:
|
|
3493
|
+
// a pixel means whatever was last painted there, and this function is pure), and
|
|
3494
|
+
// one pass resolves everything:
|
|
3495
|
+
//
|
|
3496
|
+
// seed leaf -> DOI selects the slots -> the slot at the cursor is the focus.
|
|
3497
|
+
//
|
|
3498
|
+
// The old code had no seed, so it mapped the cursor pixel onto the WHOLE-LEAF
|
|
3499
|
+
// axis instead. On a real library that is about one leaf per pixel, while the
|
|
3500
|
+
// rows on screen are the ~35 DOI slots at ~16px apart — so one row of mouse
|
|
3501
|
+
// travel moved the focus by ~30 leaves. You could not hover from 06 Feb to
|
|
3502
|
+
// 07 Feb; it jumped straight to 06 Mar.
|
|
3503
|
+
//
|
|
3504
|
+
// Resist the urge to iterate this to a fixed point (focus the slot under the
|
|
3505
|
+
// cursor, re-select, repeat). It converges, and it is worse: pointing just past
|
|
3506
|
+
// the last open day lands on the collapsed month below, and the chase then
|
|
3507
|
+
// re-centres INSIDE it — 27 Jan jumps to 27 Feb. One pass steps into the month
|
|
3508
|
+
// and lets the next hover open it.
|
|
3509
|
+
const px = o.focus?.px != null ? clamp(o.focus.px, min, max) : null;
|
|
3510
|
+
const seed =
|
|
3511
|
+
o.focus?.leaf != null
|
|
3512
|
+
? clamp(o.focus.leaf, 0, n - 1)
|
|
3513
|
+
: px != null
|
|
3514
|
+
? // No seed (the first frame, before anything has been drawn). The leaf
|
|
3515
|
+
// axis is the only axis there is; it is a rough guess, and the very next
|
|
3516
|
+
// pointermove replaces it with the real row.
|
|
3517
|
+
resolveFocus({ px }, basePositions(leaves, sizing)).slot
|
|
3518
|
+
: 0;
|
|
3519
|
+
|
|
3520
|
+
const slots = usesDOI
|
|
3521
|
+
? selectDOI(root, { ...o, budget, focusLeaf: seed })
|
|
3522
|
+
: selectAll(root);
|
|
3523
|
+
const S = slots.length;
|
|
3524
|
+
const basePos = basePositions(slots, sizing);
|
|
3525
|
+
|
|
3526
|
+
// The row under the cursor. Safe to read off the BASE axis: the fisheye pins
|
|
3527
|
+
// `px` as a fixed point, so the slot whose base band contains it is exactly the
|
|
3528
|
+
// slot whose DRAWN band contains it — which is what makes the row you click the
|
|
3529
|
+
// row you pointed at.
|
|
3530
|
+
const focusSlot =
|
|
3531
|
+
px != null ? slotAtPixel(basePos, px) : slotOfLeaf(slots, seed);
|
|
3532
|
+
const focusLeaf = px != null ? slots[focusSlot].leafStart : seed;
|
|
3533
|
+
const focus = {
|
|
3534
|
+
px: px ?? (basePos[focusSlot] + basePos[focusSlot + 1]) / 2,
|
|
3535
|
+
slot: focusSlot,
|
|
3536
|
+
};
|
|
3537
|
+
|
|
3538
|
+
// The lens distorts whatever base it is handed — equal spans or mass-weighted.
|
|
3539
|
+
// It only needs monotonicity, which both give it.
|
|
3540
|
+
const pos = usesFisheye
|
|
3541
|
+
? distort(basePos, { focusPx: focus.px, distortion: o.distortion })
|
|
3542
|
+
: basePos;
|
|
3543
|
+
|
|
3544
|
+
const { rows, maxCount } = buildRows(root, slots, pos, {
|
|
3545
|
+
focusSlot: focus.slot,
|
|
3546
|
+
});
|
|
3547
|
+
|
|
3548
|
+
return {
|
|
3549
|
+
rows,
|
|
3550
|
+
maxCount,
|
|
3551
|
+
focusLeaf,
|
|
3552
|
+
focusPx: focus.px,
|
|
3553
|
+
focusSlot: focus.slot,
|
|
3554
|
+
slots: S,
|
|
3555
|
+
leaves: n,
|
|
3556
|
+
};
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
/** Which slot holds a given leaf. Slots are ordered, so binary search. */
|
|
3560
|
+
function slotOfLeaf(slots, leaf) {
|
|
3561
|
+
let lo = 0;
|
|
3562
|
+
let hi = slots.length - 1;
|
|
3563
|
+
while (lo < hi) {
|
|
3564
|
+
const mid = (lo + hi) >> 1;
|
|
3565
|
+
if (slots[mid].leafEnd <= leaf) lo = mid + 1;
|
|
3566
|
+
else hi = mid;
|
|
3567
|
+
}
|
|
3568
|
+
return lo;
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/**
|
|
3572
|
+
* One stylesheet, injected once. Everything is expressed as a CSS custom
|
|
3573
|
+
* property on `.fisheye-nav`, so a host restyles the widget without a build step
|
|
3574
|
+
* and without the widget knowing anything about the host's design system.
|
|
3575
|
+
*
|
|
3576
|
+
* Colors are derived from a single hue + the host's text color, and the widget
|
|
3577
|
+
* inherits `color-scheme`, so it lands correctly in a light or a dark app
|
|
3578
|
+
* without being told which it is in.
|
|
3579
|
+
*/
|
|
3580
|
+
const CSS = `
|
|
3581
|
+
.fisheye-nav {
|
|
3582
|
+
--fn-accent: #3b82f6;
|
|
3583
|
+
--fn-fg: currentColor;
|
|
3584
|
+
--fn-muted: color-mix(in oklab, currentColor 55%, transparent);
|
|
3585
|
+
--fn-band: color-mix(in oklab, currentColor 8%, transparent);
|
|
3586
|
+
--fn-band-hover: color-mix(in oklab, currentColor 16%, transparent);
|
|
3587
|
+
--fn-rule: color-mix(in oklab, currentColor 18%, transparent);
|
|
3588
|
+
--fn-bar: color-mix(in oklab, var(--fn-accent) 45%, transparent);
|
|
3589
|
+
--fn-font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
3590
|
+
--fn-size: 11px;
|
|
3591
|
+
|
|
3592
|
+
position: relative;
|
|
3593
|
+
display: block;
|
|
3594
|
+
width: 100%;
|
|
3595
|
+
height: 100%;
|
|
3596
|
+
font-family: var(--fn-font);
|
|
3597
|
+
font-size: var(--fn-size);
|
|
3598
|
+
color: var(--fn-fg);
|
|
3599
|
+
user-select: none;
|
|
3600
|
+
}
|
|
3601
|
+
.fisheye-nav svg { display: block; width: 100%; height: 100%; overflow: hidden; }
|
|
3602
|
+
.fisheye-nav .fn-row { cursor: pointer; }
|
|
3603
|
+
.fisheye-nav .fn-band {
|
|
3604
|
+
fill: var(--fn-band);
|
|
3605
|
+
stroke: var(--fn-rule);
|
|
3606
|
+
stroke-width: 0.5;
|
|
3607
|
+
shape-rendering: crispEdges;
|
|
3608
|
+
}
|
|
3609
|
+
.fisheye-nav .fn-row:hover .fn-band { fill: var(--fn-band-hover); }
|
|
3610
|
+
.fisheye-nav .fn-row[data-kind="branch"] .fn-band { fill: color-mix(in oklab, currentColor 4%, transparent); }
|
|
3611
|
+
.fisheye-nav .fn-row[data-kind="elision"] .fn-band { fill: none; stroke-dasharray: 2 2; }
|
|
3612
|
+
.fisheye-nav .fn-row[data-focused="true"] .fn-band {
|
|
3613
|
+
fill: color-mix(in oklab, var(--fn-accent) 22%, transparent);
|
|
3614
|
+
stroke: var(--fn-accent);
|
|
3615
|
+
stroke-width: 1;
|
|
3616
|
+
}
|
|
3617
|
+
.fisheye-nav .fn-selected .fn-band {
|
|
3618
|
+
stroke: var(--fn-accent);
|
|
3619
|
+
stroke-width: 1.5;
|
|
3620
|
+
}
|
|
3621
|
+
.fisheye-nav .fn-bar { fill: var(--fn-bar); }
|
|
3622
|
+
.fisheye-nav .fn-label {
|
|
3623
|
+
fill: var(--fn-fg);
|
|
3624
|
+
dominant-baseline: middle;
|
|
3625
|
+
pointer-events: none;
|
|
3626
|
+
white-space: pre;
|
|
3627
|
+
}
|
|
3628
|
+
.fisheye-nav .fn-row[data-kind="branch"] .fn-label,
|
|
3629
|
+
.fisheye-nav .fn-row[data-kind="elision"] .fn-label { fill: var(--fn-muted); }
|
|
3630
|
+
.fisheye-nav .fn-count { fill: var(--fn-muted); text-anchor: end; dominant-baseline: middle; pointer-events: none; }
|
|
3631
|
+
.fisheye-nav .fn-spine { fill: var(--fn-rule); }
|
|
3632
|
+
.fisheye-nav .fn-empty { fill: var(--fn-muted); text-anchor: middle; }
|
|
3633
|
+
|
|
3634
|
+
/* ── Settings gear + popover (opt out with controls: false) ──────────────────
|
|
3635
|
+
Neutral defaults driven by the same --fn-* variables as the chart, so a host
|
|
3636
|
+
restyles the panel without patching the library. */
|
|
3637
|
+
.fisheye-nav .fn-gear {
|
|
3638
|
+
position: absolute; top: 2px; right: 4px; z-index: 6;
|
|
3639
|
+
width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px;
|
|
3640
|
+
background: transparent; color: var(--fn-muted);
|
|
3641
|
+
font-size: 12px; line-height: 18px; cursor: pointer;
|
|
3642
|
+
}
|
|
3643
|
+
.fisheye-nav .fn-gear:hover,
|
|
3644
|
+
.fisheye-nav .fn-gear.on { color: var(--fn-fg); background: var(--fn-band-hover); }
|
|
3645
|
+
/* Visibility is driven by the .fn-open CLASS, not by the hidden attribute: the
|
|
3646
|
+
UA's "[hidden] { display: none }" is a single-attribute selector, so any rule
|
|
3647
|
+
here that sets display outranks it and setting .hidden silently does nothing.
|
|
3648
|
+
(It did exactly that — every popover in the demo rendered open.) The hidden
|
|
3649
|
+
attribute is still kept in sync, for assistive tech.
|
|
3650
|
+
NB: no backticks in this comment — the whole stylesheet is a JS template
|
|
3651
|
+
literal, and a backtick here would terminate it. */
|
|
3652
|
+
/* The popover is the ONE place the widget paints its own background, so it is
|
|
3653
|
+
the one place "inherit the host's color" stops working: an inherited
|
|
3654
|
+
foreground over a system background is a coin toss (a dark app's near-white
|
|
3655
|
+
text landed on Canvas-white and vanished). Background and foreground must
|
|
3656
|
+
come from the same place — so they are a system PAIR by default, and a host
|
|
3657
|
+
overriding one is expected to override the other. color-scheme makes the
|
|
3658
|
+
pair, and the native selects and sliders inside, follow the host's theme. */
|
|
3659
|
+
.fisheye-nav .fn-panel {
|
|
3660
|
+
position: absolute; top: 22px; right: 4px; z-index: 7;
|
|
3661
|
+
width: 220px; padding: 8px; display: none; flex-direction: column; gap: 6px;
|
|
3662
|
+
color-scheme: var(--fn-scheme, light dark);
|
|
3663
|
+
background: var(--fn-panel, Canvas);
|
|
3664
|
+
color: var(--fn-panel-fg, CanvasText);
|
|
3665
|
+
border: 1px solid var(--fn-rule); border-radius: 6px;
|
|
3666
|
+
box-shadow: 0 6px 20px rgb(0 0 0 / 0.28);
|
|
3667
|
+
}
|
|
3668
|
+
.fisheye-nav .fn-panel.fn-open { display: flex; }
|
|
3669
|
+
.fisheye-nav .fn-row-ctl { display: flex; align-items: center; gap: 6px; }
|
|
3670
|
+
.fisheye-nav .fn-ctl-label { flex: 0 0 76px; opacity: 0.75; }
|
|
3671
|
+
.fisheye-nav .fn-ctl-select {
|
|
3672
|
+
flex: 1; min-width: 0; font: inherit; padding: 2px 4px;
|
|
3673
|
+
color: inherit; background: transparent;
|
|
3674
|
+
border: 1px solid var(--fn-rule); border-radius: 4px;
|
|
3675
|
+
}
|
|
3676
|
+
.fisheye-nav .fn-ctl-slider { flex: 1; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
3677
|
+
.fisheye-nav .fn-ctl-slider input { flex: 1; min-width: 0; accent-color: var(--fn-accent); }
|
|
3678
|
+
.fisheye-nav .fn-ctl-val {
|
|
3679
|
+
flex: 0 0 30px; text-align: right; opacity: 0.6;
|
|
3680
|
+
font-variant-numeric: tabular-nums;
|
|
3681
|
+
}
|
|
3682
|
+
.fisheye-nav .fn-ctl-more summary { cursor: pointer; opacity: 0.75; margin-bottom: 4px; }
|
|
3683
|
+
.fisheye-nav .fn-ctl-more[open] { border-top: 1px solid var(--fn-rule); padding-top: 6px; }
|
|
3684
|
+
.fisheye-nav .fn-ctl-reset {
|
|
3685
|
+
align-self: flex-start; font: inherit; padding: 2px 8px; cursor: pointer;
|
|
3686
|
+
color: inherit; background: transparent;
|
|
3687
|
+
border: 1px solid var(--fn-rule); border-radius: 4px;
|
|
3688
|
+
}
|
|
3689
|
+
/* A control that cannot affect the current lens says so, rather than silently
|
|
3690
|
+
doing nothing when dragged. */
|
|
3691
|
+
.fisheye-nav .fn-disabled { opacity: 0.4; }
|
|
3692
|
+
`;
|
|
3693
|
+
|
|
3694
|
+
let injected = false;
|
|
3695
|
+
|
|
3696
|
+
function injectStyle(doc = globalThis.document) {
|
|
3697
|
+
if (injected || !doc?.head) return;
|
|
3698
|
+
const el = doc.createElement("style");
|
|
3699
|
+
el.setAttribute("data-fisheye-nav", "");
|
|
3700
|
+
el.textContent = CSS;
|
|
3701
|
+
doc.head.appendChild(el);
|
|
3702
|
+
injected = true;
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/**
|
|
3706
|
+
* The renderers. Both consume the SAME rows, so neither knows (or can know)
|
|
3707
|
+
* which layout strategy produced them — that is what keeps `style` and `layout`
|
|
3708
|
+
* genuinely orthogonal instead of a 6-way switch.
|
|
3709
|
+
*
|
|
3710
|
+
* flat one column, indented by depth. Internal nodes get a real row (their
|
|
3711
|
+
* synthetic header cell), so "what level am I on" is answerable at a
|
|
3712
|
+
* glance. This is an outline.
|
|
3713
|
+
* icicle one column per depth; a node's band is its children's span. This is
|
|
3714
|
+
* a partition diagram whose vertical axis happens to be fisheyed.
|
|
3715
|
+
*/
|
|
3716
|
+
|
|
3717
|
+
|
|
3718
|
+
const STYLES = ["flat", "icicle"];
|
|
3719
|
+
|
|
3720
|
+
const RENDER_DEFAULTS = {
|
|
3721
|
+
/** px of indent per level (flat). */
|
|
3722
|
+
indent: 11,
|
|
3723
|
+
/** Draw the count histogram INSIDE each band (the row is the bar's track, the
|
|
3724
|
+
* label sits on top). False hides it. */
|
|
3725
|
+
bars: true,
|
|
3726
|
+
/** px of clear space kept at the right edge. */
|
|
3727
|
+
gutter: 6,
|
|
3728
|
+
/** don't draw a label in a band thinner than this — it would just be noise. */
|
|
3729
|
+
minLabelPx: 9,
|
|
3730
|
+
/** count-histogram scale. Log by default — see scales.js for why. */
|
|
3731
|
+
barScale: "log",
|
|
3732
|
+
/** count formatter for the histogram tooltip/label. */
|
|
3733
|
+
format: (n) => n.toLocaleString(),
|
|
3734
|
+
};
|
|
3735
|
+
|
|
3736
|
+
/**
|
|
3737
|
+
* @param {SVGElement} svg
|
|
3738
|
+
* @param {{rows: any[], maxCount: number}} model a `layout()` result
|
|
3739
|
+
* @param {object} opts width, height, style, selectedId, + RENDER_DEFAULTS
|
|
3740
|
+
*/
|
|
3741
|
+
function render(svg, model, opts) {
|
|
3742
|
+
const o = { ...RENDER_DEFAULTS, ...opts };
|
|
3743
|
+
injectStyle(svg.ownerDocument);
|
|
3744
|
+
|
|
3745
|
+
const root = select(svg);
|
|
3746
|
+
const { rows, maxCount } = model;
|
|
3747
|
+
const { width, height, style } = o;
|
|
3748
|
+
|
|
3749
|
+
root.attr("viewBox", `0 0 ${width} ${height}`);
|
|
3750
|
+
|
|
3751
|
+
if (!rows.length) {
|
|
3752
|
+
root.selectAll(".fn-row").remove();
|
|
3753
|
+
root
|
|
3754
|
+
.selectAll(".fn-empty")
|
|
3755
|
+
.data([0])
|
|
3756
|
+
.join("text")
|
|
3757
|
+
.attr("class", "fn-empty")
|
|
3758
|
+
.attr("x", width / 2)
|
|
3759
|
+
.attr("y", height / 2)
|
|
3760
|
+
.text(o.emptyLabel ?? "Nothing to show");
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3763
|
+
root.selectAll(".fn-empty").remove();
|
|
3764
|
+
|
|
3765
|
+
// The bar's track is the ROW, not a column of its own: length encodes photo
|
|
3766
|
+
// mass while the band's height encodes the lens, so the column reads as a
|
|
3767
|
+
// histogram silhouette (the original AutoGallery design). The label is drawn
|
|
3768
|
+
// over it.
|
|
3769
|
+
const trackW = width - o.gutter;
|
|
3770
|
+
const maxDepth = rows.reduce((m, r) => Math.max(m, r.depth), 1);
|
|
3771
|
+
const geom =
|
|
3772
|
+
style === "icicle"
|
|
3773
|
+
? icicleGeom(maxDepth, trackW)
|
|
3774
|
+
: flatGeom(o.indent, trackW);
|
|
3775
|
+
|
|
3776
|
+
// Which rows get a bar. In an icicle the inner nodes own a column of their own,
|
|
3777
|
+
// so they show their AGGREGATE — the count of the whole subtree they stand for.
|
|
3778
|
+
// In flat mode they don't: a branch's band spans all of its children, so a bar
|
|
3779
|
+
// across it would just be a block sitting behind them.
|
|
3780
|
+
const barred = (d) =>
|
|
3781
|
+
o.bars &&
|
|
3782
|
+
d.count > 0 &&
|
|
3783
|
+
(d.terminal || (style === "icicle" && d.kind === "branch"));
|
|
3784
|
+
|
|
3785
|
+
// Flat: ONE scale, absolute lengths, because every row shares one track — a
|
|
3786
|
+
// per-row scale would draw equal counts at different lengths depending on how
|
|
3787
|
+
// deep the row sits, which is a lie. A deeper row starts further right, so its
|
|
3788
|
+
// bar is clipped to its own band.
|
|
3789
|
+
const flatScale = makeBarScale(maxCount, Math.max(1, trackW), {
|
|
3790
|
+
type: o.barScale,
|
|
3791
|
+
});
|
|
3792
|
+
|
|
3793
|
+
// Icicle: each depth is a column, so each depth is its OWN histogram. A year
|
|
3794
|
+
// (40,000 photos) and a day (300) do not belong on one scale — shared, every
|
|
3795
|
+
// year's bar pegs at full width and the column encodes nothing. So the bar is a
|
|
3796
|
+
// fraction of the row's own width, taken from a scale over that depth's max.
|
|
3797
|
+
const depthScale = new Map();
|
|
3798
|
+
if (style === "icicle") {
|
|
3799
|
+
const maxAt = new Map();
|
|
3800
|
+
for (const r of rows)
|
|
3801
|
+
if (barred(r))
|
|
3802
|
+
maxAt.set(r.depth, Math.max(maxAt.get(r.depth) ?? 0, r.count));
|
|
3803
|
+
for (const [depth, m] of maxAt)
|
|
3804
|
+
depthScale.set(
|
|
3805
|
+
depth,
|
|
3806
|
+
makeBarScale(m, 1, { type: o.barScale, minLenPx: 0.05 }),
|
|
3807
|
+
);
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
const barLen = (d) => {
|
|
3811
|
+
if (!barred(d)) return 0;
|
|
3812
|
+
const w = Math.max(0, geom.w(d));
|
|
3813
|
+
return style === "icicle"
|
|
3814
|
+
? (depthScale.get(d.depth)?.(d.count) ?? 0) * w
|
|
3815
|
+
: Math.min(flatScale(d.count), w);
|
|
3816
|
+
};
|
|
3817
|
+
|
|
3818
|
+
const g = root
|
|
3819
|
+
.selectAll("g.fn-row")
|
|
3820
|
+
.data(rows, (d) => d.id)
|
|
3821
|
+
.join((enter) => {
|
|
3822
|
+
const e = enter.append("g").attr("class", "fn-row");
|
|
3823
|
+
e.append("rect").attr("class", "fn-band");
|
|
3824
|
+
e.append("rect").attr("class", "fn-bar");
|
|
3825
|
+
e.append("text").attr("class", "fn-label");
|
|
3826
|
+
e.append("title");
|
|
3827
|
+
return e;
|
|
3828
|
+
});
|
|
3829
|
+
|
|
3830
|
+
g.attr("data-kind", (d) => d.kind)
|
|
3831
|
+
.attr("data-depth", (d) => d.depth)
|
|
3832
|
+
.attr("data-id", (d) => d.id)
|
|
3833
|
+
.attr("data-focused", (d) => (d.focused ? "true" : null))
|
|
3834
|
+
.classed("fn-selected", (d) => d.id === o.selectedId);
|
|
3835
|
+
|
|
3836
|
+
g.select("title").text(
|
|
3837
|
+
(d) =>
|
|
3838
|
+
`${d.label}\n${o.format(d.count)}` +
|
|
3839
|
+
(d.elided ? ` · ${d.elided} groups collapsed` : ""),
|
|
3840
|
+
);
|
|
3841
|
+
|
|
3842
|
+
g.select("rect.fn-band")
|
|
3843
|
+
.attr("x", (d) => geom.x(d))
|
|
3844
|
+
.attr("y", (d) => d.y0)
|
|
3845
|
+
.attr("width", (d) => Math.max(0, geom.w(d)))
|
|
3846
|
+
// Hairline bands still need to be clickable; 1px floor keeps the hit target.
|
|
3847
|
+
.attr("height", (d) => Math.max(1, d.thickness))
|
|
3848
|
+
// Drop the border on sub-pixel bands. In the compressed tail of an
|
|
3849
|
+
// undecimated fisheye, hundreds of hairline bands stack their 0.5px strokes
|
|
3850
|
+
// into a solid wall that swallows both the fill and the labels — the
|
|
3851
|
+
// silhouette turns into a white smear. Inline style, because a stylesheet
|
|
3852
|
+
// rule would beat a presentation attribute.
|
|
3853
|
+
.style("stroke-width", (d) => (d.thickness >= 2 ? 0.5 : 0));
|
|
3854
|
+
|
|
3855
|
+
// Same x and same height as the band it fills — so the lens magnifies the bar
|
|
3856
|
+
// along with its row, and the label (drawn after) sits over it.
|
|
3857
|
+
g.select("rect.fn-bar")
|
|
3858
|
+
.attr("x", (d) => geom.x(d))
|
|
3859
|
+
.attr("y", (d) => d.y0)
|
|
3860
|
+
.attr("width", barLen)
|
|
3861
|
+
.attr("height", (d) => Math.max(1, d.thickness));
|
|
3862
|
+
|
|
3863
|
+
g.select("text.fn-label")
|
|
3864
|
+
.attr("x", (d) => geom.x(d) + 4)
|
|
3865
|
+
.attr("y", (d) => d.y)
|
|
3866
|
+
.attr("font-weight", (d) =>
|
|
3867
|
+
d.kind === "header" || d.kind === "branch" ? 600 : 400,
|
|
3868
|
+
)
|
|
3869
|
+
.attr("opacity", (d) => (labelled(d, style, o) ? 1 : 0))
|
|
3870
|
+
.text((d) => (labelled(d, style, o) ? labelFor(d, geom.w(d)) : ""));
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
/** flat: indent by depth, span the rest of the track. */
|
|
3874
|
+
const flatGeom = (indent, barX) => ({
|
|
3875
|
+
x: (d) => (d.depth - 1) * indent,
|
|
3876
|
+
w: (d) => barX - (d.depth - 1) * indent,
|
|
3877
|
+
});
|
|
3878
|
+
|
|
3879
|
+
/**
|
|
3880
|
+
* icicle: one column per level. A terminal row spans from its own column to the
|
|
3881
|
+
* end of the track, so a leaf that stops short in a ragged tree still reads as a
|
|
3882
|
+
* leaf rather than leaving a hole (same convention as d3's icicle).
|
|
3883
|
+
*/
|
|
3884
|
+
const icicleGeom = (maxDepth, barX) => {
|
|
3885
|
+
const colW = barX / maxDepth;
|
|
3886
|
+
return {
|
|
3887
|
+
x: (d) => (d.depth - 1) * colW,
|
|
3888
|
+
w: (d) => (d.terminal ? barX - (d.depth - 1) * colW : colW),
|
|
3889
|
+
};
|
|
3890
|
+
};
|
|
3891
|
+
|
|
3892
|
+
/**
|
|
3893
|
+
* A branch's band spans its whole subtree, so a label centred in it would land
|
|
3894
|
+
* ON TOP of its children. In flat mode the branch's synthetic header row already
|
|
3895
|
+
* carries the name, so the branch itself stays silent — shading only. In icicle
|
|
3896
|
+
* mode the branch owns a column of its own, so it must be labelled.
|
|
3897
|
+
*/
|
|
3898
|
+
function labelled(d, style, o) {
|
|
3899
|
+
if (style !== "icicle" && d.kind === "branch") return false;
|
|
3900
|
+
// The row you are ON and the row you have CHOSEN always say what they are, at
|
|
3901
|
+
// any thickness. The floor exists to stop hundreds of unreadable labels piling
|
|
3902
|
+
// up — it must never silence the two rows the user is actually asking about.
|
|
3903
|
+
// Without this, `fisheye` (which draws every leaf, so every band is a couple of
|
|
3904
|
+
// pixels) is a lens onto a column of anonymous stripes: you hover, and nothing
|
|
3905
|
+
// tells you where you are.
|
|
3906
|
+
if (d.focused || d.id === o.selectedId) return true;
|
|
3907
|
+
return d.thickness >= o.minLabelPx;
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
function labelFor(d, w) {
|
|
3911
|
+
const text = d.kind === "elision" ? `⋯ ${d.label}` : (d.label ?? "");
|
|
3912
|
+
// ~6px per char at 11px system-ui; cheap and good enough to avoid measuring
|
|
3913
|
+
// text in a hot path that runs on every mousemove.
|
|
3914
|
+
const max = Math.max(0, Math.floor((w - 8) / 6));
|
|
3915
|
+
return text.length > max ? text.slice(0, Math.max(1, max - 1)) + "…" : text;
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
/**
|
|
3919
|
+
* The settings gear + popover, owned by the widget.
|
|
3920
|
+
*
|
|
3921
|
+
* Same contract as @john-guerra/d3-zoomable-axis's scent controls, deliberately:
|
|
3922
|
+
* controls a ⚙ that live-tunes the lens. On by default; pass false to
|
|
3923
|
+
* suppress it and drive the options from your own UI.
|
|
3924
|
+
* persistKey a localStorage key, so a user's tuned lens survives a reload.
|
|
3925
|
+
* Pass none and listen for the "settings" event to use your own
|
|
3926
|
+
* store instead.
|
|
3927
|
+
*
|
|
3928
|
+
* A consumer should not have to hand-roll this — AutoGallery did, and every
|
|
3929
|
+
* other consumer would have too.
|
|
3930
|
+
*/
|
|
3931
|
+
|
|
3932
|
+
|
|
3933
|
+
/** What the panel tunes AND persists. Never data, accessors, or sizes. */
|
|
3934
|
+
const PERSIST_KEYS = [
|
|
3935
|
+
"style",
|
|
3936
|
+
"layout",
|
|
3937
|
+
"sizeBy",
|
|
3938
|
+
"distortion",
|
|
3939
|
+
"minRowPx",
|
|
3940
|
+
"barScale",
|
|
3941
|
+
"apiWeight",
|
|
3942
|
+
"distanceWeight",
|
|
3943
|
+
"treeWeight",
|
|
3944
|
+
"countWeight",
|
|
3945
|
+
];
|
|
3946
|
+
|
|
3947
|
+
function readStore(key) {
|
|
3948
|
+
if (!key || typeof localStorage === "undefined") return null;
|
|
3949
|
+
try {
|
|
3950
|
+
const s = JSON.parse(localStorage.getItem(key) ?? "null");
|
|
3951
|
+
return s && typeof s === "object" ? s : null;
|
|
3952
|
+
} catch {
|
|
3953
|
+
return null;
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
function writeStore(key, obj) {
|
|
3958
|
+
if (!key || typeof localStorage === "undefined") return;
|
|
3959
|
+
try {
|
|
3960
|
+
const picked = {};
|
|
3961
|
+
for (const k of PERSIST_KEYS) if (k in obj) picked[k] = obj[k];
|
|
3962
|
+
localStorage.setItem(key, JSON.stringify(picked));
|
|
3963
|
+
} catch {
|
|
3964
|
+
/* quota / disabled storage — persistence is best-effort */
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
/** Keep only known keys with sane values: a stale or hand-edited store must not
|
|
3969
|
+
* be able to wedge the widget into an unrenderable state. */
|
|
3970
|
+
function sanitize(raw, defaults) {
|
|
3971
|
+
const out = {};
|
|
3972
|
+
if (!raw) return out;
|
|
3973
|
+
const num = (v, lo, hi) => {
|
|
3974
|
+
const n = Number(v);
|
|
3975
|
+
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : undefined;
|
|
3976
|
+
};
|
|
3977
|
+
const oneOf = (v, list) => (list.includes(v) ? v : undefined);
|
|
3978
|
+
const put = (k, v) => {
|
|
3979
|
+
if (v !== undefined) out[k] = v;
|
|
3980
|
+
};
|
|
3981
|
+
|
|
3982
|
+
put("style", oneOf(raw.style, STYLES));
|
|
3983
|
+
put("layout", oneOf(raw.layout, LAYOUTS));
|
|
3984
|
+
put("sizeBy", oneOf(raw.sizeBy, SIZE_BY));
|
|
3985
|
+
put("barScale", oneOf(raw.barScale, BAR_SCALES));
|
|
3986
|
+
put("distortion", num(raw.distortion, 1, 12));
|
|
3987
|
+
put("minRowPx", num(raw.minRowPx, 8, 48));
|
|
3988
|
+
put("apiWeight", num(raw.apiWeight, 0, 3));
|
|
3989
|
+
put("distanceWeight", num(raw.distanceWeight, 0, 3));
|
|
3990
|
+
put("treeWeight", num(raw.treeWeight, 0, 1));
|
|
3991
|
+
put("countWeight", num(raw.countWeight, 0, 2));
|
|
3992
|
+
return { ...defaults, ...out };
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
const SELECTS = [
|
|
3996
|
+
{
|
|
3997
|
+
key: "style",
|
|
3998
|
+
label: "View",
|
|
3999
|
+
options: [
|
|
4000
|
+
["flat", "List (indented)"],
|
|
4001
|
+
["icicle", "Icicle (columns)"],
|
|
4002
|
+
],
|
|
4003
|
+
},
|
|
4004
|
+
{
|
|
4005
|
+
key: "layout",
|
|
4006
|
+
label: "Lens",
|
|
4007
|
+
options: [
|
|
4008
|
+
["hybrid", "Hybrid — magnify + fold"],
|
|
4009
|
+
["fisheye", "Fisheye — every group"],
|
|
4010
|
+
["doi", "Interest — full rows"],
|
|
4011
|
+
["uniform", "Uniform — no lens"],
|
|
4012
|
+
],
|
|
4013
|
+
},
|
|
4014
|
+
{
|
|
4015
|
+
key: "sizeBy",
|
|
4016
|
+
label: "Band size",
|
|
4017
|
+
options: [
|
|
4018
|
+
["slots", "Interest (equal)"],
|
|
4019
|
+
["count", "Photos (mass)"],
|
|
4020
|
+
],
|
|
4021
|
+
},
|
|
4022
|
+
{
|
|
4023
|
+
key: "barScale",
|
|
4024
|
+
label: "Bars",
|
|
4025
|
+
options: [
|
|
4026
|
+
["log", "Log"],
|
|
4027
|
+
["sqrt", "Square root"],
|
|
4028
|
+
["linear", "Linear"],
|
|
4029
|
+
],
|
|
4030
|
+
},
|
|
4031
|
+
];
|
|
4032
|
+
|
|
4033
|
+
const SLIDERS = [
|
|
4034
|
+
{ key: "distortion", label: "Distortion", min: 1, max: 12, step: 0.5 },
|
|
4035
|
+
{ key: "minRowPx", label: "Row size", min: 8, max: 48, step: 1, unit: "px" },
|
|
4036
|
+
];
|
|
4037
|
+
|
|
4038
|
+
// The DOI weights live behind a disclosure: they are where the lens's FEEL comes
|
|
4039
|
+
// from, but four extra sliders up front would bury the two knobs most people
|
|
4040
|
+
// actually want.
|
|
4041
|
+
const WEIGHTS = [
|
|
4042
|
+
{ key: "distanceWeight", label: "Focus falloff", min: 0, max: 3, step: 0.1 },
|
|
4043
|
+
{
|
|
4044
|
+
key: "treeWeight",
|
|
4045
|
+
label: "Structure vs. list",
|
|
4046
|
+
min: 0,
|
|
4047
|
+
max: 1,
|
|
4048
|
+
step: 0.05,
|
|
4049
|
+
},
|
|
4050
|
+
{ key: "countWeight", label: "Busy groups", min: 0, max: 2, step: 0.05 },
|
|
4051
|
+
{ key: "apiWeight", label: "Same-level bias", min: 0, max: 3, step: 0.1 },
|
|
4052
|
+
];
|
|
4053
|
+
|
|
4054
|
+
/**
|
|
4055
|
+
* @param {HTMLElement} host the widget root
|
|
4056
|
+
* @param {{get:()=>object, set:(patch:object)=>void, reset:()=>void}} io
|
|
4057
|
+
* @returns {{sync:()=>void, destroy:()=>void}}
|
|
4058
|
+
*/
|
|
4059
|
+
function buildControls(host, io) {
|
|
4060
|
+
const doc = host.ownerDocument;
|
|
4061
|
+
const el = (tag, cls, props) =>
|
|
4062
|
+
Object.assign(doc.createElement(tag), { className: cls, ...props });
|
|
4063
|
+
|
|
4064
|
+
const gear = el("button", "fn-gear", {
|
|
4065
|
+
type: "button",
|
|
4066
|
+
innerHTML: "⚙",
|
|
4067
|
+
title: "Lens settings",
|
|
4068
|
+
});
|
|
4069
|
+
gear.setAttribute("aria-label", "Lens settings");
|
|
4070
|
+
gear.setAttribute("aria-expanded", "false");
|
|
4071
|
+
|
|
4072
|
+
const panel = el("div", "fn-panel");
|
|
4073
|
+
panel.hidden = true;
|
|
4074
|
+
panel.setAttribute("role", "group");
|
|
4075
|
+
panel.setAttribute("aria-label", "Lens settings");
|
|
4076
|
+
|
|
4077
|
+
const inputs = new Map();
|
|
4078
|
+
|
|
4079
|
+
const row = (label, control) => {
|
|
4080
|
+
const r = el("label", "fn-row-ctl");
|
|
4081
|
+
r.appendChild(el("span", "fn-ctl-label", { textContent: label }));
|
|
4082
|
+
r.appendChild(control);
|
|
4083
|
+
return r;
|
|
4084
|
+
};
|
|
4085
|
+
|
|
4086
|
+
for (const s of SELECTS) {
|
|
4087
|
+
const sel = el("select", "fn-ctl-select");
|
|
4088
|
+
for (const [value, text] of s.options) {
|
|
4089
|
+
sel.appendChild(el("option", "", { value, textContent: text }));
|
|
4090
|
+
}
|
|
4091
|
+
sel.addEventListener("change", () => io.set({ [s.key]: sel.value }));
|
|
4092
|
+
inputs.set(s.key, sel);
|
|
4093
|
+
panel.appendChild(row(s.label, sel));
|
|
4094
|
+
}
|
|
4095
|
+
|
|
4096
|
+
const slider = (s) => {
|
|
4097
|
+
const wrap = el("span", "fn-ctl-slider");
|
|
4098
|
+
const input = el("input", "", {
|
|
4099
|
+
type: "range",
|
|
4100
|
+
min: String(s.min),
|
|
4101
|
+
max: String(s.max),
|
|
4102
|
+
step: String(s.step),
|
|
4103
|
+
});
|
|
4104
|
+
const out = el("span", "fn-ctl-val");
|
|
4105
|
+
input.addEventListener("input", () => {
|
|
4106
|
+
out.textContent = input.value + (s.unit ?? "");
|
|
4107
|
+
io.set({ [s.key]: Number(input.value) });
|
|
4108
|
+
});
|
|
4109
|
+
wrap.append(input, out);
|
|
4110
|
+
inputs.set(s.key, input);
|
|
4111
|
+
return { wrap, input, out, spec: s };
|
|
4112
|
+
};
|
|
4113
|
+
|
|
4114
|
+
const sliders = [];
|
|
4115
|
+
for (const s of SLIDERS) {
|
|
4116
|
+
const made = slider(s);
|
|
4117
|
+
sliders.push(made);
|
|
4118
|
+
panel.appendChild(row(s.label, made.wrap));
|
|
4119
|
+
}
|
|
4120
|
+
|
|
4121
|
+
const details = el("details", "fn-ctl-more");
|
|
4122
|
+
details.appendChild(el("summary", "", { textContent: "Interest weights" }));
|
|
4123
|
+
for (const s of WEIGHTS) {
|
|
4124
|
+
const made = slider(s);
|
|
4125
|
+
sliders.push(made);
|
|
4126
|
+
details.appendChild(row(s.label, made.wrap));
|
|
4127
|
+
}
|
|
4128
|
+
panel.appendChild(details);
|
|
4129
|
+
|
|
4130
|
+
const reset = el("button", "fn-ctl-reset", {
|
|
4131
|
+
type: "button",
|
|
4132
|
+
textContent: "Reset",
|
|
4133
|
+
});
|
|
4134
|
+
reset.addEventListener("click", () => io.reset());
|
|
4135
|
+
panel.appendChild(reset);
|
|
4136
|
+
|
|
4137
|
+
// `.fn-open` is what actually shows the panel — see style.js. `hidden` is kept
|
|
4138
|
+
// in sync for assistive tech, but it cannot be trusted to hide anything on its
|
|
4139
|
+
// own once the stylesheet sets `display`.
|
|
4140
|
+
const toggle = (open = panel.hidden) => {
|
|
4141
|
+
panel.hidden = !open;
|
|
4142
|
+
panel.classList.toggle("fn-open", open);
|
|
4143
|
+
gear.classList.toggle("on", open);
|
|
4144
|
+
gear.setAttribute("aria-expanded", String(open));
|
|
4145
|
+
};
|
|
4146
|
+
gear.addEventListener("click", () => toggle());
|
|
4147
|
+
|
|
4148
|
+
// Esc closes; a click outside closes. Both are what a popover is expected to
|
|
4149
|
+
// do, and neither is worth making every consumer reimplement.
|
|
4150
|
+
const onKey = (e) => {
|
|
4151
|
+
if (e.key === "Escape" && !panel.hidden) toggle(false);
|
|
4152
|
+
};
|
|
4153
|
+
const onDocClick = (e) => {
|
|
4154
|
+
if (panel.hidden) return;
|
|
4155
|
+
if (!panel.contains(e.target) && e.target !== gear) toggle(false);
|
|
4156
|
+
};
|
|
4157
|
+
host.addEventListener("keydown", onKey);
|
|
4158
|
+
doc.addEventListener("click", onDocClick, true);
|
|
4159
|
+
|
|
4160
|
+
host.append(gear, panel);
|
|
4161
|
+
|
|
4162
|
+
const sync = () => {
|
|
4163
|
+
const o = io.get();
|
|
4164
|
+
for (const [key, input] of inputs) {
|
|
4165
|
+
const v = o[key];
|
|
4166
|
+
if (input.tagName === "SELECT") input.value = String(v);
|
|
4167
|
+
else input.value = String(v);
|
|
4168
|
+
}
|
|
4169
|
+
for (const s of sliders) {
|
|
4170
|
+
s.out.textContent = String(o[s.spec.key]) + (s.spec.unit ?? "");
|
|
4171
|
+
}
|
|
4172
|
+
// Distortion means nothing without a lens; say so rather than letting the
|
|
4173
|
+
// user drag a slider that does nothing.
|
|
4174
|
+
const lensed = o.layout === "fisheye" || o.layout === "hybrid";
|
|
4175
|
+
const dist = inputs.get("distortion");
|
|
4176
|
+
dist.disabled = !lensed;
|
|
4177
|
+
dist.closest(".fn-row-ctl").classList.toggle("fn-disabled", !lensed);
|
|
4178
|
+
// Likewise the interest weights only matter when DOI is doing the selecting.
|
|
4179
|
+
const doi = o.layout === "doi" || o.layout === "hybrid";
|
|
4180
|
+
details.classList.toggle("fn-disabled", !doi);
|
|
4181
|
+
for (const w of WEIGHTS) inputs.get(w.key).disabled = !doi;
|
|
4182
|
+
};
|
|
4183
|
+
|
|
4184
|
+
sync();
|
|
4185
|
+
|
|
4186
|
+
return {
|
|
4187
|
+
sync,
|
|
4188
|
+
destroy() {
|
|
4189
|
+
host.removeEventListener("keydown", onKey);
|
|
4190
|
+
doc.removeEventListener("click", onDocClick, true);
|
|
4191
|
+
gear.remove();
|
|
4192
|
+
panel.remove();
|
|
4193
|
+
},
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
/**
|
|
4198
|
+
* The widget: a reactivewidgets-style DOM node.
|
|
4199
|
+
*
|
|
4200
|
+
* const nav = fisheyeNav({ data, keys: ["year","month","day"] });
|
|
4201
|
+
* nav.addEventListener("input", () => console.log(nav.value)); // the path
|
|
4202
|
+
* nav.value = [{key:"year", value:"2024"}]; // set silently
|
|
4203
|
+
*
|
|
4204
|
+
* `value` is the path to whatever the user clicked, truncated at that depth:
|
|
4205
|
+
* click a day and you get the full 3-level path; click the "2024" band and you
|
|
4206
|
+
* get just [{key:"year", value:"2024"}]. Hover drives the LENS and never fires
|
|
4207
|
+
* `input` — otherwise every mousemove would look like a selection.
|
|
4208
|
+
*/
|
|
4209
|
+
|
|
4210
|
+
|
|
4211
|
+
const NAV_DEFAULTS = {
|
|
4212
|
+
...LAYOUT_DEFAULTS,
|
|
4213
|
+
...RENDER_DEFAULTS,
|
|
4214
|
+
style: "flat",
|
|
4215
|
+
/** A ⚙ that live-tunes the lens. Pass false to drive the options yourself. */
|
|
4216
|
+
controls: true,
|
|
4217
|
+
/** localStorage key, so a tuned lens survives a reload. Omit and listen for
|
|
4218
|
+
* the "settings" event to use your own store instead. */
|
|
4219
|
+
persistKey: null,
|
|
4220
|
+
// `hybrid`, not `fisheye`, is the default: pure fisheye draws every leaf, which
|
|
4221
|
+
// on a real library (thousands of days) means thousands of sub-pixel bands —
|
|
4222
|
+
// honest about density, unreadable as a navigator. Hybrid bounds the row count
|
|
4223
|
+
// to what actually fits and stays readable at any library size. Pick `fisheye`
|
|
4224
|
+
// deliberately when you want the undecimated silhouette.
|
|
4225
|
+
layout: "hybrid",
|
|
4226
|
+
width: null, // null = measure the container
|
|
4227
|
+
height: null,
|
|
4228
|
+
};
|
|
4229
|
+
|
|
4230
|
+
function fisheyeNav(options = {}) {
|
|
4231
|
+
const doc = options.document ?? globalThis.document;
|
|
4232
|
+
const el = doc.createElement("div");
|
|
4233
|
+
el.className = "fisheye-nav";
|
|
4234
|
+
injectStyle(doc);
|
|
4235
|
+
|
|
4236
|
+
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
4237
|
+
svg.setAttribute("preserveAspectRatio", "none");
|
|
4238
|
+
svg.setAttribute("role", "listbox");
|
|
4239
|
+
el.appendChild(svg);
|
|
4240
|
+
|
|
4241
|
+
// A persisted lens wins over the defaults but NOT over what the caller passed
|
|
4242
|
+
// explicitly — a host pinning `style: "icicle"` in code means it, and a stale
|
|
4243
|
+
// localStorage entry must not silently override it.
|
|
4244
|
+
const stored = sanitize(readStore(options.persistKey), {});
|
|
4245
|
+
let opts = { ...NAV_DEFAULTS, ...stored, ...options };
|
|
4246
|
+
let root = null; // the normalized hierarchy
|
|
4247
|
+
let model = null; // the last layout result
|
|
4248
|
+
let hoverPx = null; // the pinned cursor pixel, or null when not hovering
|
|
4249
|
+
// Where DOI opens its window. Moves ONLY on a click (see the click handler) —
|
|
4250
|
+
// never on hover, or the rows reshuffle under the cursor. Null = follow the
|
|
4251
|
+
// selection.
|
|
4252
|
+
let anchorLeaf = null;
|
|
4253
|
+
let size = { width: 0, height: 0 };
|
|
4254
|
+
let controls = null;
|
|
4255
|
+
|
|
4256
|
+
// --- state derivation -----------------------------------------------------
|
|
4257
|
+
|
|
4258
|
+
const rebuild = () => {
|
|
4259
|
+
root = normalize$1({
|
|
4260
|
+
data: opts.data,
|
|
4261
|
+
keys: opts.keys,
|
|
4262
|
+
root: opts.root,
|
|
4263
|
+
children: opts.children,
|
|
4264
|
+
value: opts.value,
|
|
4265
|
+
count: opts.count,
|
|
4266
|
+
label: opts.label,
|
|
4267
|
+
// Flat mode needs a real row per internal node to have something to indent;
|
|
4268
|
+
// an icicle gives parents a column instead, so headers would be redundant.
|
|
4269
|
+
headers: opts.style === "flat",
|
|
4270
|
+
});
|
|
4271
|
+
};
|
|
4272
|
+
|
|
4273
|
+
/** The leaf the current selection sits on — where the lens rests when not hovering. */
|
|
4274
|
+
const selectedLeaf = () => {
|
|
4275
|
+
const path = el.value;
|
|
4276
|
+
if (!path?.length || !root) return 0;
|
|
4277
|
+
let node = root;
|
|
4278
|
+
for (const step of path) {
|
|
4279
|
+
const next = (node.children ?? []).find(
|
|
4280
|
+
(c) =>
|
|
4281
|
+
!c.data.header &&
|
|
4282
|
+
c.data.key === step.key &&
|
|
4283
|
+
c.data.value === step.value,
|
|
4284
|
+
);
|
|
4285
|
+
if (!next) break;
|
|
4286
|
+
node = next;
|
|
4287
|
+
}
|
|
4288
|
+
return node.leafStart ?? 0;
|
|
4289
|
+
};
|
|
4290
|
+
|
|
4291
|
+
// `idOfPath`, not a second copy of the same join. This built the id with a
|
|
4292
|
+
// SPACE while the rows built it with PATH_SEP, so any selection deeper than one
|
|
4293
|
+
// level failed to match its row: selecting a day never highlighted it (and,
|
|
4294
|
+
// once the label floor learned about the selection, never labelled it either).
|
|
4295
|
+
const selectedId = () => {
|
|
4296
|
+
const path = el.value;
|
|
4297
|
+
return path?.length ? idOfPath(path) : null;
|
|
4298
|
+
};
|
|
4299
|
+
|
|
4300
|
+
const measure = () => {
|
|
4301
|
+
const w = opts.width ?? el.clientWidth;
|
|
4302
|
+
const h = opts.height ?? el.clientHeight;
|
|
4303
|
+
size = { width: Math.max(0, w), height: Math.max(0, h) };
|
|
4304
|
+
};
|
|
4305
|
+
|
|
4306
|
+
const draw = () => {
|
|
4307
|
+
if (!root) rebuild();
|
|
4308
|
+
measure();
|
|
4309
|
+
if (!size.width || !size.height) return;
|
|
4310
|
+
|
|
4311
|
+
model = layout(root, {
|
|
4312
|
+
...opts,
|
|
4313
|
+
height: size.height,
|
|
4314
|
+
// Two focuses, and keeping them apart is the whole trick: the PIXEL steers
|
|
4315
|
+
// the lens, continuously; the LEAF decides which rows DOI opens, and it
|
|
4316
|
+
// moves only on a click. See the pointermove and click handlers.
|
|
4317
|
+
focus: { px: hoverPx ?? undefined, leaf: anchorLeaf ?? selectedLeaf() },
|
|
4318
|
+
});
|
|
4319
|
+
|
|
4320
|
+
render(svg, model, { ...opts, ...size, selectedId: selectedId() });
|
|
4321
|
+
};
|
|
4322
|
+
|
|
4323
|
+
// --- interaction ----------------------------------------------------------
|
|
4324
|
+
|
|
4325
|
+
/**
|
|
4326
|
+
* HOVER MOVES THE LENS. IT DOES NOT RE-SELECT. This is load-bearing.
|
|
4327
|
+
*
|
|
4328
|
+
* A lens is only stable over a FIXED set of rows. DOI selection is not fixed —
|
|
4329
|
+
* it is a function of the focus — so re-running it on every mousemove reshuffles
|
|
4330
|
+
* the rows under the cursor and the leaf you are pointing at changes
|
|
4331
|
+
* discontinuously: hovering down through January you get 27 Jan, Feb, 23 Feb,
|
|
4332
|
+
* 27 Feb, Mar, and whole weeks are simply unreachable. (Plain `fisheye` selects
|
|
4333
|
+
* every leaf, so its set is fixed, and it never does this.)
|
|
4334
|
+
*
|
|
4335
|
+
* Re-anchoring DOI on whatever the cursor touches does not fix it either: the
|
|
4336
|
+
* row budget is fixed, so opening one region collapses another, and the tail
|
|
4337
|
+
* oscillates — 9 more, 8 more, ... 2 more, 9 more, forever.
|
|
4338
|
+
*
|
|
4339
|
+
* So which rows are open changes only when the user SAYS so, by clicking. Hover
|
|
4340
|
+
* magnifies what is there; a click drills in and re-opens the window around it
|
|
4341
|
+
* (via `selectedLeaf`). That is the DOI-browser tradition, and it is what the
|
|
4342
|
+
* original AutoGallery sidebar did — its decimated set was fixed too, which is
|
|
4343
|
+
* precisely why hovering it never jumped.
|
|
4344
|
+
*/
|
|
4345
|
+
svg.addEventListener("pointermove", (e) => {
|
|
4346
|
+
hoverPx = pointer(e, svg)[1];
|
|
4347
|
+
draw();
|
|
4348
|
+
});
|
|
4349
|
+
|
|
4350
|
+
svg.addEventListener("pointerleave", () => {
|
|
4351
|
+
hoverPx = null; // the lens snaps back to the selection
|
|
4352
|
+
draw();
|
|
4353
|
+
});
|
|
4354
|
+
|
|
4355
|
+
/**
|
|
4356
|
+
* EVERY ITEM MUST BE SELECTABLE. The column shows ~40 rows of a library that
|
|
4357
|
+
* may hold thousands of leaves, so most of them are, at any moment, folded into
|
|
4358
|
+
* a closed subtree or an elision. Reaching them therefore has to be a matter of
|
|
4359
|
+
* DRILLING IN — and every click must make progress toward that, or some leaves
|
|
4360
|
+
* are unreachable forever.
|
|
4361
|
+
*
|
|
4362
|
+
* Which is why a click moves the DOI anchor INTO what was clicked, rather than
|
|
4363
|
+
* relying on the selection to do it:
|
|
4364
|
+
*
|
|
4365
|
+
* - a leaf, a header, a closed subtree -> select it; the window opens there.
|
|
4366
|
+
* - an ELISION (a run of closed siblings, "7 more") stands for no single node,
|
|
4367
|
+
* so there is no path to select. It used to select the elision's PARENT —
|
|
4368
|
+
* which, if the parent was already selected, changed nothing at all: click
|
|
4369
|
+
* it forever and the days inside it never open. A dead end, and a whole
|
|
4370
|
+
* region of the library you could see but never reach.
|
|
4371
|
+
*
|
|
4372
|
+
* So the anchor goes to the first leaf the clicked row stands for. That run
|
|
4373
|
+
* always scores highest next time, so it always opens. Drill enough and any
|
|
4374
|
+
* leaf gets its own row.
|
|
4375
|
+
*/
|
|
4376
|
+
svg.addEventListener("click", (e) => {
|
|
4377
|
+
const g = e.target.closest?.("g.fn-row");
|
|
4378
|
+
if (!g) return;
|
|
4379
|
+
const row = model?.rows.find((r) => r.id === g.getAttribute("data-id"));
|
|
4380
|
+
if (!row) return;
|
|
4381
|
+
|
|
4382
|
+
anchorLeaf = row.leafStart; // always progress: open where you clicked
|
|
4383
|
+
|
|
4384
|
+
// `row.path` — NOT `row.parentNode.path`. An elision row already carries its
|
|
4385
|
+
// parent's path (bands.js), and `parentNode` is a d3 hierarchy node, whose
|
|
4386
|
+
// `.path` is d3's own path(target) METHOD. Reading it got a function, whose
|
|
4387
|
+
// `.length` is 1 (its arity), so the emptiness guard passed and `.map` threw —
|
|
4388
|
+
// killing the handler before it could redraw. The anchor moved, the screen did
|
|
4389
|
+
// not, and clicking a "2 more" row did nothing at all, forever: the two years
|
|
4390
|
+
// it stood for could be seen and never reached.
|
|
4391
|
+
const path = row.path;
|
|
4392
|
+
if (!path?.length) {
|
|
4393
|
+
draw(); // an elision at the root: nothing to select, but still drill in
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4396
|
+
el.setValue(path.map((p) => ({ ...p })));
|
|
4397
|
+
});
|
|
4398
|
+
|
|
4399
|
+
// --- reactive-widget contract --------------------------------------------
|
|
4400
|
+
|
|
4401
|
+
const widget = ReactiveWidget(el, {
|
|
4402
|
+
value: options.selected ?? null,
|
|
4403
|
+
// Called on `input` AND on a silent `.value = x` set — so a host pushing the
|
|
4404
|
+
// selection in gets the highlight without an event echoing back out.
|
|
4405
|
+
showValue: () => draw(),
|
|
4406
|
+
});
|
|
4407
|
+
|
|
4408
|
+
/** Merge new options and redraw. Re-normalizes only when it has to. */
|
|
4409
|
+
el.update = (next = {}) => {
|
|
4410
|
+
const restructure =
|
|
4411
|
+
("data" in next && next.data !== opts.data) ||
|
|
4412
|
+
("root" in next && next.root !== opts.root) ||
|
|
4413
|
+
("keys" in next && String(next.keys) !== String(opts.keys)) ||
|
|
4414
|
+
("style" in next && next.style !== opts.style); // headers on/off
|
|
4415
|
+
opts = { ...opts, ...next };
|
|
4416
|
+
if (!STYLES.includes(opts.style)) opts.style = "flat";
|
|
4417
|
+
if (restructure) rebuild();
|
|
4418
|
+
controls?.sync();
|
|
4419
|
+
draw();
|
|
4420
|
+
};
|
|
4421
|
+
|
|
4422
|
+
// --- settings -------------------------------------------------------------
|
|
4423
|
+
|
|
4424
|
+
/** Apply a settings change: persist it, tell the host, redraw. */
|
|
4425
|
+
const applySettings = (patch) => {
|
|
4426
|
+
el.update(patch);
|
|
4427
|
+
writeStore(opts.persistKey, opts);
|
|
4428
|
+
// A host that wants its own store (or wants to mirror the lens elsewhere)
|
|
4429
|
+
// listens for this instead of passing a persistKey.
|
|
4430
|
+
el.dispatchEvent(
|
|
4431
|
+
new CustomEvent("settings", { detail: el.getSettings(), bubbles: true }),
|
|
4432
|
+
);
|
|
4433
|
+
};
|
|
4434
|
+
|
|
4435
|
+
/** The tunable lens settings — the subset that is persisted. */
|
|
4436
|
+
el.getSettings = () => {
|
|
4437
|
+
const out = {};
|
|
4438
|
+
for (const k of PERSIST_KEYS) out[k] = opts[k];
|
|
4439
|
+
return out;
|
|
4440
|
+
};
|
|
4441
|
+
|
|
4442
|
+
el.setSettings = (patch) =>
|
|
4443
|
+
applySettings(sanitize({ ...el.getSettings(), ...patch }, {}));
|
|
4444
|
+
|
|
4445
|
+
el.resetSettings = () => {
|
|
4446
|
+
const defaults = {};
|
|
4447
|
+
for (const k of PERSIST_KEYS) defaults[k] = NAV_DEFAULTS[k];
|
|
4448
|
+
applySettings(defaults);
|
|
4449
|
+
};
|
|
4450
|
+
|
|
4451
|
+
if (opts.controls !== false) {
|
|
4452
|
+
controls = buildControls(el, {
|
|
4453
|
+
get: () => opts,
|
|
4454
|
+
set: applySettings,
|
|
4455
|
+
reset: () => el.resetSettings(),
|
|
4456
|
+
});
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
el.getRows = () => model?.rows ?? [];
|
|
4460
|
+
el.getRoot = () => root;
|
|
4461
|
+
// Real leaves only. The synthetic header cells are an internal device for the
|
|
4462
|
+
// flat view's outline; leaking them here would make the count depend on the
|
|
4463
|
+
// current style, which is nobody's idea of a leaf count.
|
|
4464
|
+
el.getLeafCount = () =>
|
|
4465
|
+
root ? leavesOf(root).filter((n) => !n.data.header).length : 0;
|
|
4466
|
+
|
|
4467
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
4468
|
+
const ro = new ResizeObserver(() => draw());
|
|
4469
|
+
ro.observe(el);
|
|
4470
|
+
el.destroy = () => {
|
|
4471
|
+
ro.disconnect();
|
|
4472
|
+
controls?.destroy();
|
|
4473
|
+
};
|
|
4474
|
+
} else {
|
|
4475
|
+
el.destroy = () => controls?.destroy();
|
|
4476
|
+
}
|
|
4477
|
+
|
|
4478
|
+
rebuild();
|
|
4479
|
+
draw();
|
|
4480
|
+
return widget;
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
exports.LAYOUTS = LAYOUTS;
|
|
4484
|
+
exports.LAYOUT_DEFAULTS = LAYOUT_DEFAULTS;
|
|
4485
|
+
exports.STYLES = STYLES;
|
|
4486
|
+
exports.basePositions = basePositions;
|
|
4487
|
+
exports.buildRows = buildRows;
|
|
4488
|
+
exports.default = fisheyeNav;
|
|
4489
|
+
exports.distort = distort;
|
|
4490
|
+
exports.doiScore = doiScore;
|
|
4491
|
+
exports.fisheyeNav = fisheyeNav;
|
|
4492
|
+
exports.fisheyePosition = fisheyePosition;
|
|
4493
|
+
exports.layout = layout;
|
|
4494
|
+
exports.leafDistance = leafDistance;
|
|
4495
|
+
exports.leavesOf = leavesOf;
|
|
4496
|
+
exports.makeBarScale = makeBarScale;
|
|
4497
|
+
exports.nodesOf = nodesOf;
|
|
4498
|
+
exports.normalize = normalize$1;
|
|
4499
|
+
exports.selectAll = selectAll;
|
|
4500
|
+
exports.selectDOI = selectDOI;
|
|
4501
|
+
exports.slotAtPixel = slotAtPixel;
|
|
4502
|
+
|
|
4503
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4504
|
+
|
|
4505
|
+
}));
|