smart-labels 0.0.1

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/src/index.js ADDED
@@ -0,0 +1,453 @@
1
+ import * as d3 from "d3";
2
+ // Based on https://observablehq.com/@d3/voronoi-labels by Mike Bostock
3
+
4
+ // ISC License
5
+
6
+ // Copyright 2018–2023 Observable, Inc.
7
+ // Permission to use, copy, modify, and/or distribute this software for any
8
+ // purpose with or without fee is hereby granted, provided that the above
9
+ // copyright notice and this permission notice appear in all copies.
10
+
11
+ // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
12
+ // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
13
+ // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
14
+ // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
15
+ // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
16
+ // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
17
+ // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18
+ export function smartLabels(
19
+ data,
20
+ {
21
+ x = (d) => d[0], // x coordinate accessor, expected to be in pixels
22
+ y = (d) => d[1], // y coordinate accessor, expected to be in pixels
23
+ label = (d, i) => i, // Accessor for the label
24
+ fill = "#333", // label fill color
25
+ stroke = "white", // label stroke color
26
+ threshold = 2000, // Areas over this size would get labels
27
+ width = null,
28
+ height = null,
29
+ target = null, // Where do you want it to draw
30
+ font = "10px sans-serif",
31
+ hover = true, // Show label of the hovered point
32
+ onHover = (i) => i, // callback when hovered, will pass the index of the selected element
33
+ hoverFont = "bolder 12px sans-serif",
34
+ labelsInCentroids = true,
35
+
36
+ backgroundFill = "#fefefe01", // What to paint the bg rect of the labels. Needed for the onHover
37
+ strokeWidth = 5,
38
+
39
+ showVoronoi = false,
40
+ voronoiStroke = "#ccc",
41
+
42
+ showAnchors = false,
43
+ anchorsStroke = "orange",
44
+ anchorsFill = "none",
45
+
46
+ useOcclusion = true,
47
+ occludedStyle = "opacity: 0.2", // css style rules to be used on occluded labels
48
+
49
+ // For debugging
50
+ showPoints = false,
51
+ pointsFill = "#ccc",
52
+ pointsSelectedFill = "firebrick",
53
+ pointsStroke = "#ccc",
54
+ renderer = "svg",
55
+ debug = false,
56
+ } = {}
57
+ ) {
58
+ data = data.filter(
59
+ (d) =>
60
+ x(d) !== undefined && x(d) !== null && y(d) !== undefined && y(d) !== null
61
+ );
62
+
63
+ let xExtent = d3.extent(data, x),
64
+ yExtent = d3.extent(data, y);
65
+ width = width || xExtent[1] - xExtent[0];
66
+ height = height || yExtent[1] - yExtent[0];
67
+
68
+ if (debug) console.log("✅ smartLabels renderer", renderer);
69
+ if (renderer.toLocaleLowerCase() === "canvas") {
70
+ target =
71
+ d3.select(target) ||
72
+ d3.create("canvas").attr("width", width).attr("height", height);
73
+ useOcclusion = false;
74
+ } else {
75
+ target = target || d3.create("svg").attr("viewBox", [0, 0, width, height]);
76
+ }
77
+
78
+ const delaunay = d3.Delaunay.from(data, x, y);
79
+ const voronoi = delaunay.voronoi([
80
+ xExtent[0] - 1,
81
+ yExtent[0] - 1,
82
+ xExtent[1] + 1,
83
+ yExtent[1] + 1,
84
+ ]);
85
+
86
+ let cells = data.map((d, i) => [d, voronoi.cellPolygon(i)]);
87
+ // Replace null cells with the nearest one
88
+ cells = cells
89
+ .map(([d, cell]) => [d, getNearestCell(d, cell)])
90
+ .map(([d, cell]) => ({ d, cell, show: -d3.polygonArea(cell) > threshold }));
91
+
92
+ // cells can be null when we have duplicated coords
93
+ // https://github.com/d3/d3-delaunay/issues/106
94
+ function getNearestCell(d, cell) {
95
+ if (!cell) {
96
+ const i = delaunay.find(x(d), y(d));
97
+ if (i === -1) {
98
+ console.log("couldn't find cell", i, d, x(d), y(d));
99
+ return null;
100
+ }
101
+ cell = cells[i][1];
102
+ }
103
+ return cell;
104
+ }
105
+
106
+ function renderSVG() {
107
+ let anchors = null;
108
+ if (useOcclusion) {
109
+ target
110
+ .selectAll("style.smartLabels")
111
+ .data([0])
112
+ .join("style")
113
+ .attr("class", "smartLabels").html(`
114
+ svg g.labels > text.occluded:not(.selected) { ${occludedStyle} }
115
+ `);
116
+ }
117
+
118
+ const mouseRect = target
119
+ .selectAll("rect.smartLabels")
120
+ .data([0])
121
+ .join("rect")
122
+ .attr("class", "smartLabels")
123
+ .attr("width", width)
124
+ .attr("height", height)
125
+ .attr("fill", backgroundFill)
126
+ .attr("stroke", "none");
127
+
128
+ const orient = {
129
+ top: (text) => text.attr("text-anchor", "middle").attr("y", -6),
130
+ right: (text) =>
131
+ text.attr("text-anchor", "start").attr("dy", "0.35em").attr("x", 6),
132
+ bottom: (text) =>
133
+ text.attr("text-anchor", "middle").attr("dy", "0.71em").attr("y", 6),
134
+ left: (text) =>
135
+ text.attr("text-anchor", "end").attr("dy", "0.35em").attr("x", -6),
136
+ };
137
+
138
+ if (showAnchors || labelsInCentroids) {
139
+ anchors = target
140
+ .selectAll("g#anchors")
141
+ .data([cells])
142
+ .join("g")
143
+ .attr("pointer-events", "none")
144
+ .attr("id", "anchors")
145
+ .attr("stroke", anchorsStroke)
146
+ .attr("fill", anchorsFill)
147
+ .selectAll("path.anchor")
148
+ .data((d) => d)
149
+ .join("path")
150
+ .attr("class", "anchor")
151
+ .attr("display", ({ show }) => (show ? null : "none"))
152
+ .attr("d", ({ d, cell }) =>
153
+ cell ? `M${d3.polygonCentroid(cell)}L${x(d)},${y(d)}` : null
154
+ );
155
+ }
156
+
157
+ if (showVoronoi) {
158
+ target
159
+ .selectAll("path#voronoi")
160
+ .data([1])
161
+ .join("path")
162
+ .attr("id", "voronoi")
163
+ .attr("stroke", voronoiStroke)
164
+ .attr("pointer-events", "none")
165
+ .attr("d", voronoi.render());
166
+ }
167
+
168
+ let points;
169
+ if (showPoints) {
170
+ points = target
171
+ .selectAll("circle#points")
172
+ .data([1])
173
+ .join("path")
174
+ .attr("id", "points")
175
+ .attr("pointer-events", "none")
176
+ .attr("stroke", pointsStroke)
177
+ .attr("fill", pointsFill)
178
+ .attr("d", delaunay.renderPoints(null, 2));
179
+ }
180
+
181
+ const labels = target
182
+ .selectAll("g.labels")
183
+ .data([cells])
184
+ .join("g")
185
+ .attr("class", "labels")
186
+ .attr("fill", fill)
187
+
188
+ .attr("stroke", stroke)
189
+ .attr("stroke-width", strokeWidth)
190
+ .attr("paint-order", "stroke")
191
+ .attr("pointer-events", "none")
192
+ .selectAll("text")
193
+ .data((d) => d)
194
+ .join("text")
195
+ .style("font", font)
196
+ .each(function ({ d, cell }) {
197
+ if (!cell) return;
198
+ const [cx, cy] = d3.polygonCentroid(cell);
199
+ const angle =
200
+ (Math.round((Math.atan2(cy - y(d), cx - x(d)) / Math.PI) * 2) + 4) %
201
+ 4;
202
+
203
+ d3.select(this).call(
204
+ angle === 0
205
+ ? orient.right
206
+ : angle === 3
207
+ ? orient.top
208
+ : angle === 1
209
+ ? orient.bottom
210
+ : orient.left
211
+ );
212
+ })
213
+ .attr("transform", ({ d, cell }) => {
214
+ if (labelsInCentroids) {
215
+ const [cx, cy] = d3.polygonCentroid(cell);
216
+
217
+ return `translate(${cx}, ${cy})`;
218
+ } else {
219
+ return `translate(${x(d)}, ${y(d)})`;
220
+ }
221
+ })
222
+ .attr("display", ({ show }) => (show ? null : "none"))
223
+ .text(({ d }, i, all) => label(d, i, all));
224
+
225
+ let selected = null;
226
+ if (hover) {
227
+ mouseRect.on("mousemove", (event) => {
228
+ // Find the selected point
229
+ const i = delaunay.find(...d3.pointer(event));
230
+ if (i === selected) return;
231
+ selected = i;
232
+
233
+ // Update labels
234
+ labels
235
+ .attr("display", ({ show }, i) =>
236
+ i === selected || show ? null : "none"
237
+ )
238
+ .style("font", (_, i) => (i === selected ? hoverFont : font))
239
+ .classed("selected", (_, i) => i === selected)
240
+ .filter((_, i) => i === selected)
241
+ .raise();
242
+
243
+ anchors &&
244
+ anchors.attr("display", ({ show }, i) =>
245
+ show || i === selected ? null : "none"
246
+ );
247
+
248
+ if (showPoints) {
249
+ points
250
+ .attr("fill", (_, i) =>
251
+ i === selected ? pointsSelectedFill : pointsSelectedFill
252
+ )
253
+ .classed("selected", (_, i) => i === selected);
254
+ }
255
+
256
+ if (onHover && typeof onHover === "function") onHover(i);
257
+ });
258
+ }
259
+
260
+ // Setup a MutationObserver to wait for the element to be rendered
261
+ // https://stackoverflow.com/questions/15875128/is-there-element-rendered-event
262
+ if (useOcclusion) {
263
+ const observer = new MutationObserver(function () {
264
+ if (document.contains(target.node())) {
265
+ target.call(occlusion, "g.labels > text");
266
+ observer.disconnect();
267
+ }
268
+ });
269
+
270
+ observer.observe(document, {
271
+ attributes: false,
272
+ childList: true,
273
+ characterData: false,
274
+ subtree: true,
275
+ });
276
+ }
277
+ } // renderSVG
278
+
279
+ function renderCanvas() {
280
+ const context = target.node().getContext("2d");
281
+ // const ratio = window.devicePixelRatio || 1;
282
+ // target.attr("width", width * ratio).attr("height", height * ratio);
283
+ // context.scale(ratio, ratio);
284
+ // context.clearRect(0, 0, width, height);
285
+
286
+ if (showAnchors || labelsInCentroids) {
287
+ cells.forEach(({ d, cell, show }) => {
288
+ if (!show) return;
289
+ const [cx, cy] = d3.polygonCentroid(cell);
290
+ context.beginPath();
291
+ context.moveTo(cx, cy);
292
+ context.lineTo(x(d), y(d));
293
+ context.strokeStyle = anchorsStroke;
294
+ context.stroke();
295
+ });
296
+ }
297
+
298
+ if (showVoronoi) {
299
+ context.beginPath();
300
+ voronoi.render(context);
301
+ context.strokeStyle = voronoiStroke;
302
+ context.stroke();
303
+ }
304
+
305
+ if (showPoints) {
306
+ context.beginPath();
307
+ delaunay.renderPoints(context, 2);
308
+ context.fillStyle = pointsFill;
309
+ context.fill();
310
+ context.strokeStyle = pointsStroke;
311
+ context.stroke();
312
+ }
313
+
314
+ cells.forEach(({ d, cell, show }) => {
315
+ if (!show) return;
316
+ const [cx, cy] = d3.polygonCentroid(cell);
317
+ const angle =
318
+ (Math.round((Math.atan2(cy - y(d), cx - x(d)) / Math.PI) * 2) + 4) % 4;
319
+
320
+ context.save();
321
+ context.translate(
322
+ labelsInCentroids ? cx : x(d),
323
+ labelsInCentroids ? cy : y(d)
324
+ );
325
+ context.textAlign =
326
+ angle === 0
327
+ ? "left"
328
+ : angle === 1
329
+ ? "center"
330
+ : angle === 2
331
+ ? "right"
332
+ : "center";
333
+ context.textBaseline =
334
+ angle === 0
335
+ ? "middle"
336
+ : angle === 1
337
+ ? "top"
338
+ : angle === 2
339
+ ? "middle"
340
+ : "bottom";
341
+ context.font = font;
342
+ context.fillStyle = fill;
343
+ context.strokeStyle = stroke;
344
+ context.lineWidth = strokeWidth;
345
+ context.strokeText(label(d), 0, 0);
346
+ context.fillText(label(d), 0, 0);
347
+ context.restore();
348
+ });
349
+
350
+ let selected = null;
351
+ if (hover) {
352
+ if (debug) console.log("setting onmousemove", target);
353
+
354
+ target.on("mousemove", (event) => {
355
+ const [mx, my] = d3.pointer(event);
356
+ const i = delaunay.find(mx, my);
357
+ if (i === selected) return;
358
+ selected = i;
359
+
360
+ context.clearRect(0, 0, width, height);
361
+ cells.forEach(({ d, cell, show }, index) => {
362
+ if (!show && index !== selected) return;
363
+ const [cx, cy] = d3.polygonCentroid(cell);
364
+ const angle =
365
+ (Math.round((Math.atan2(cy - y(d), cx - x(d)) / Math.PI) * 2) + 4) %
366
+ 4;
367
+
368
+ context.save();
369
+ context.translate(
370
+ labelsInCentroids ? cx : x(d),
371
+ labelsInCentroids ? cy : y(d)
372
+ );
373
+ context.textAlign =
374
+ angle === 0
375
+ ? "left"
376
+ : angle === 1
377
+ ? "center"
378
+ : angle === 2
379
+ ? "right"
380
+ : "center";
381
+ context.textBaseline =
382
+ angle === 0
383
+ ? "middle"
384
+ : angle === 1
385
+ ? "top"
386
+ : angle === 2
387
+ ? "middle"
388
+ : "bottom";
389
+ context.font = index === selected ? hoverFont : font;
390
+ context.fillStyle = fill;
391
+ context.strokeStyle = stroke;
392
+ context.lineWidth = strokeWidth;
393
+ context.strokeText(label(d), 0, 0);
394
+ context.fillText(label(d), 0, 0);
395
+ context.restore();
396
+ });
397
+
398
+ if (onHover && typeof onHover === "function") onHover(i);
399
+ });
400
+ }
401
+ } // renderCanvas
402
+
403
+ if (renderer.toLocaleLowerCase() === "canvas") {
404
+ renderCanvas();
405
+ } else {
406
+ renderSVG();
407
+ }
408
+
409
+ return Object.assign(target.node(), { delaunay, voronoi, cells });
410
+ }
411
+
412
+ // https://observablehq.com/@d3/occlusion
413
+ // ISC License
414
+
415
+ // Copyright 2020-2023 Philippe Rivière
416
+ // Permission to use, copy, modify, and/or distribute this software for any
417
+ // purpose with or without fee is hereby granted, provided that the above
418
+ // copyright notice and this permission notice appear in all copies.
419
+
420
+ // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
421
+ // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
422
+ // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
423
+ // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
424
+ // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
425
+ // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
426
+ // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
427
+ export function occlusion(svg, against = "text") {
428
+ const nodes = d3
429
+ .sort(svg.selectAll(against), (node) => +node.getAttribute("data-priority"))
430
+ .reverse()
431
+ .map((node) => {
432
+ const { x, y, width, height } = node.getBoundingClientRect();
433
+ return { node, x, y, width, height };
434
+ });
435
+
436
+ const visible = [];
437
+ for (const d of nodes) {
438
+ const occluded = visible.some((e) => intersectRect(d, e));
439
+ d3.select(d.node).classed("occluded", occluded);
440
+ if (!occluded) visible.push(d);
441
+ }
442
+ return visible;
443
+ }
444
+
445
+ // This intersection function works for rect and text, but not so much for circles.
446
+ function intersectRect(a, b) {
447
+ return !(
448
+ a.x + a.width < b.x ||
449
+ b.x + b.width < a.x ||
450
+ a.y + a.height < b.y ||
451
+ b.y + b.height < a.y
452
+ );
453
+ }