smart-labels 0.0.5 → 0.0.7

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/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Smart Labels
2
+
3
+ Add nearest on-hover and labels when there is space to your D3 chart. It should be faster than force based versions as it uses d3-delaunay, and it will choose which labels to show based on the amount of space available in the chart.
4
+
5
+ Based on the [voronoi-labels](https://observablehq.com/@d3/voronoi-labels) example by [@mbostock](https://observablehq.com/@mbostock), and combining it with [@fil's](https://observablehq.com/@fil) great [occlusion](https://observablehq.com/@fil/occlusion) example.
6
+
7
+ Receives the data, accessors for the x and y coordinates as well as the label. Expects all the coordinates to come in pixels.
8
+
9
+ ## Usage
10
+
11
+ ```html
12
+ <script src="https://cdn.jsdelivr.net/npm/smart-labels"></script>
13
+ <script>
14
+ const svg = d3.create("svg")
15
+ // 𓈓 add your own marks (e.g. points), we will add the text labels
16
+
17
+ smartLabels(data, {
18
+ target: svg, // where to draw
19
+ label: (d) => d.name, // where to find the label in the data
20
+ x: (d) => xScale(d.x), // expected in pixels
21
+ y: (d) => yScale(d.y),
22
+ labelsInCentroids : true, // Draw the labels in the centroid of the voronoi
23
+ useOcclusion: true // Make occluded labels transparent
24
+ })
25
+ </script>
26
+ ```
27
+
28
+ ### Default options
29
+
30
+ ```js
31
+ {
32
+ x = (d) => d[0], // x coordinate accessor, expected to be in pixels
33
+ y = (d) => d[1], // y coordinate accessor, expected to be in pixels
34
+ r = () => 3, // radius accessor, expected to be in pixels
35
+ label = (d, i) => i, // Accessor for the label
36
+ fill = "#333", // label fill color
37
+ stroke = "white", // label stroke color
38
+ threshold = 2000, // Areas over this size would get labels
39
+ width = null,
40
+ height = null,
41
+ target = null, // Where do you want it to draw
42
+ font = "10px sans-serif",
43
+ hover = true, // Show label of the hovered point
44
+ onHover = (i) => i, // callback when hovered, will pass the index of the selected element
45
+ hoverFont = "bolder 12px sans-serif",
46
+ labelsInCentroids = true,
47
+
48
+ backgroundFill = "#fefefe01", // What to paint the bg rect of the labels. Needed for the onHover
49
+ strokeWidth = 5,
50
+
51
+ showVoronoi = false,
52
+ voronoiStroke = "#ccc",
53
+
54
+ showAnchors = false,
55
+ anchorsStroke = "orange",
56
+ anchorsFill = "none",
57
+
58
+ useOcclusion = true,
59
+ occludedStyle = "opacity: 0.2", // css style rules to be used on occluded labels
60
+
61
+ // For debugging
62
+ showPoints = false,
63
+ pointsFill = "#ccc",
64
+ pointsSelectedFill = "firebrick",
65
+ pointsStroke = "#ccc",
66
+ renderer = "svg",
67
+ debug = false,
68
+ selected = null,
69
+ padding = 3, // label padding in pixels
70
+ }
71
+ ```
72
+
73
+
74
+ ## Known problems
75
+
76
+ * Doesn't guarantee non-overlapping
77
+ * If two marks share the same coordinates it will draw their labels and anchors in the same position. It won't be possible to hover over the repeated ones
78
+
79
+
80
+ ## More examples
81
+
82
+ For observable notebooks please see [@john-guerra/smart-labels](https://observablehq.com/@john-guerra/smart-labels#smartLabels)
83
+
84
+ For other vanilla JS examples see the [/examples](/examples) folder
@@ -1,4 +1,4 @@
1
- // smart-labels v0.0.4 Copyright (c) 2024 John Alexis Guerra Gómez
1
+ // smart-labels v0.0.6 Copyright (c) 2024 John Alexis Guerra Gómez
2
2
  import * as d3 from 'd3';
3
3
 
4
4
  // Based on https://observablehq.com/@d3/voronoi-labels by Mike Bostock
@@ -26,15 +26,18 @@ function smartLabels(
26
26
  label = (d, i) => i, // Accessor for the label
27
27
  fill = "#333", // label fill color
28
28
  stroke = "white", // label stroke color
29
- threshold = 2000, // Areas over this size would get labels
30
29
  width = null,
31
30
  height = null,
32
31
  target = null, // Where do you want it to draw
33
- font = "10px sans-serif",
32
+ renderer = "svg", // canvas or svg
33
+ font = () => "18px sans-serif",
34
34
  hover = true, // Show label of the hovered point
35
35
  onHover = (i) => i, // callback when hovered, will pass the index of the selected element
36
- hoverFont = "bolder 12px sans-serif",
36
+ hoverFont = () => "bolder 12px sans-serif",
37
37
  labelsInCentroids = true,
38
+ threshold = 2000, // Areas over this size would get labels
39
+ alwaysShow = (d) => false, // If returns true for the node, it will always show the label
40
+ showLabel = (d, cell) => alwaysShow(d) || -d3.polygonArea(cell) > threshold, // If true, show the label
38
41
 
39
42
  backgroundFill = "#fefefe01", // What to paint the bg rect of the labels. Needed for the onHover
40
43
  strokeWidth = 5,
@@ -54,7 +57,6 @@ function smartLabels(
54
57
  pointsFill = "#ccc",
55
58
  pointsSelectedFill = "firebrick",
56
59
  pointsStroke = "#ccc",
57
- renderer = "svg",
58
60
  debug = false,
59
61
  selected = null,
60
62
  padding = 3, // label padding in pixels
@@ -68,6 +70,9 @@ function smartLabels(
68
70
  y(d, index) !== null
69
71
  );
70
72
 
73
+ if (typeof font === "string") font = () => font;
74
+ if (typeof hoverFont === "string") hoverFont = () => hoverFont;
75
+
71
76
  let xExtent = d3.extent(data, x),
72
77
  yExtent = d3.extent(data, y);
73
78
  width = width || xExtent[1] - xExtent[0];
@@ -87,8 +92,17 @@ function smartLabels(
87
92
  }
88
93
 
89
94
  if (target && !checkIfTargetMatchesRenderer(target, renderer)) {
90
- if (debug) console.log("❌ smartLabels Target doesn't match the renderer", target, renderer);
91
- throw new Error("Smartlabels Target doesn't match the renderer", target, renderer);
95
+ if (debug)
96
+ console.log(
97
+ "❌ smartLabels Target doesn't match the renderer",
98
+ target,
99
+ renderer
100
+ );
101
+ throw new Error(
102
+ "Smartlabels Target doesn't match the renderer",
103
+ target,
104
+ renderer
105
+ );
92
106
  }
93
107
 
94
108
  // Try to reuse the target
@@ -120,7 +134,7 @@ function smartLabels(
120
134
  // Replace null cells with the nearest one
121
135
  cells = cells
122
136
  .map(([d, cell], index) => [d, getNearestCell(d, cell, index)])
123
- .map(([d, cell]) => ({ d, cell, show: -d3.polygonArea(cell) > threshold }));
137
+ .map(([d, cell]) => ({ d, cell, show: showLabel(d, cell) }));
124
138
 
125
139
  // cells can be null when we have duplicated coords
126
140
  // https://github.com/d3/d3-delaunay/issues/106
@@ -239,7 +253,7 @@ function smartLabels(
239
253
  .selectAll("text")
240
254
  .data((d) => d)
241
255
  .join("text")
242
- .style("font", font)
256
+ .style("font", ({d}, i) => font(d, i))
243
257
  .each(function ({ d, cell }, index) {
244
258
  if (!cell) return;
245
259
  const [cx, cy] = d3.polygonCentroid(cell);
@@ -285,7 +299,9 @@ function smartLabels(
285
299
  .attr("display", ({ show }, i) =>
286
300
  i === selected || show ? null : "none"
287
301
  )
288
- .style("font", (_, i) => (i === selected ? hoverFont : font))
302
+ .style("font", ({d}, i) =>
303
+ i === selected ? hoverFont(d, i) : font(d, i)
304
+ )
289
305
  .classed("selected", (_, i) => i === selected)
290
306
  .filter((_, i) => i === selected)
291
307
  .raise();
@@ -411,7 +427,7 @@ function smartLabels(
411
427
  : angle === 2
412
428
  ? "middle"
413
429
  : "bottom";
414
- context.font = index === selected ? hoverFont : font;
430
+ context.font = index === selected ? hoverFont(d, index) : font(d, index);
415
431
  context.fillStyle = fill;
416
432
  context.strokeStyle = stroke;
417
433
  context.lineWidth = strokeWidth;
@@ -1,4 +1,4 @@
1
- // smart-labels v0.0.4 Copyright (c) 2024 John Alexis Guerra Gómez
1
+ // smart-labels v0.0.6 Copyright (c) 2024 John Alexis Guerra Gómez
2
2
  (function (global, factory) {
3
3
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('d3')) :
4
4
  typeof define === 'function' && define.amd ? define(['d3'], factory) :
@@ -49,15 +49,18 @@ function smartLabels(
49
49
  label = (d, i) => i, // Accessor for the label
50
50
  fill = "#333", // label fill color
51
51
  stroke = "white", // label stroke color
52
- threshold = 2000, // Areas over this size would get labels
53
52
  width = null,
54
53
  height = null,
55
54
  target = null, // Where do you want it to draw
56
- font = "10px sans-serif",
55
+ renderer = "svg", // canvas or svg
56
+ font = () => "18px sans-serif",
57
57
  hover = true, // Show label of the hovered point
58
58
  onHover = (i) => i, // callback when hovered, will pass the index of the selected element
59
- hoverFont = "bolder 12px sans-serif",
59
+ hoverFont = () => "bolder 12px sans-serif",
60
60
  labelsInCentroids = true,
61
+ threshold = 2000, // Areas over this size would get labels
62
+ alwaysShow = (d) => false, // If returns true for the node, it will always show the label
63
+ showLabel = (d, cell) => alwaysShow(d) || -d3__namespace.polygonArea(cell) > threshold, // If true, show the label
61
64
 
62
65
  backgroundFill = "#fefefe01", // What to paint the bg rect of the labels. Needed for the onHover
63
66
  strokeWidth = 5,
@@ -77,7 +80,6 @@ function smartLabels(
77
80
  pointsFill = "#ccc",
78
81
  pointsSelectedFill = "firebrick",
79
82
  pointsStroke = "#ccc",
80
- renderer = "svg",
81
83
  debug = false,
82
84
  selected = null,
83
85
  padding = 3, // label padding in pixels
@@ -91,6 +93,9 @@ function smartLabels(
91
93
  y(d, index) !== null
92
94
  );
93
95
 
96
+ if (typeof font === "string") font = () => font;
97
+ if (typeof hoverFont === "string") hoverFont = () => hoverFont;
98
+
94
99
  let xExtent = d3__namespace.extent(data, x),
95
100
  yExtent = d3__namespace.extent(data, y);
96
101
  width = width || xExtent[1] - xExtent[0];
@@ -110,8 +115,17 @@ function smartLabels(
110
115
  }
111
116
 
112
117
  if (target && !checkIfTargetMatchesRenderer(target, renderer)) {
113
- if (debug) console.log("❌ smartLabels Target doesn't match the renderer", target, renderer);
114
- throw new Error("Smartlabels Target doesn't match the renderer", target, renderer);
118
+ if (debug)
119
+ console.log(
120
+ "❌ smartLabels Target doesn't match the renderer",
121
+ target,
122
+ renderer
123
+ );
124
+ throw new Error(
125
+ "Smartlabels Target doesn't match the renderer",
126
+ target,
127
+ renderer
128
+ );
115
129
  }
116
130
 
117
131
  // Try to reuse the target
@@ -143,7 +157,7 @@ function smartLabels(
143
157
  // Replace null cells with the nearest one
144
158
  cells = cells
145
159
  .map(([d, cell], index) => [d, getNearestCell(d, cell, index)])
146
- .map(([d, cell]) => ({ d, cell, show: -d3__namespace.polygonArea(cell) > threshold }));
160
+ .map(([d, cell]) => ({ d, cell, show: showLabel(d, cell) }));
147
161
 
148
162
  // cells can be null when we have duplicated coords
149
163
  // https://github.com/d3/d3-delaunay/issues/106
@@ -262,7 +276,7 @@ function smartLabels(
262
276
  .selectAll("text")
263
277
  .data((d) => d)
264
278
  .join("text")
265
- .style("font", font)
279
+ .style("font", ({d}, i) => font(d, i))
266
280
  .each(function ({ d, cell }, index) {
267
281
  if (!cell) return;
268
282
  const [cx, cy] = d3__namespace.polygonCentroid(cell);
@@ -308,7 +322,9 @@ function smartLabels(
308
322
  .attr("display", ({ show }, i) =>
309
323
  i === selected || show ? null : "none"
310
324
  )
311
- .style("font", (_, i) => (i === selected ? hoverFont : font))
325
+ .style("font", ({d}, i) =>
326
+ i === selected ? hoverFont(d, i) : font(d, i)
327
+ )
312
328
  .classed("selected", (_, i) => i === selected)
313
329
  .filter((_, i) => i === selected)
314
330
  .raise();
@@ -434,7 +450,7 @@ function smartLabels(
434
450
  : angle === 2
435
451
  ? "middle"
436
452
  : "bottom";
437
- context.font = index === selected ? hoverFont : font;
453
+ context.font = index === selected ? hoverFont(d, index) : font(d, index);
438
454
  context.fillStyle = fill;
439
455
  context.strokeStyle = stroke;
440
456
  context.lineWidth = strokeWidth;
@@ -1,2 +1,2 @@
1
- // smart-labels v0.0.4 Copyright (c) 2024 John Alexis Guerra Gómez
2
- !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("d3")):"function"==typeof define&&define.amd?define(["d3"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).smartLabels=e(t.d3)}(this,(function(t){"use strict";function e(t){var e=Object.create(null);return t&&Object.keys(t).forEach((function(o){if("default"!==o){var n=Object.getOwnPropertyDescriptor(t,o);Object.defineProperty(e,o,n.get?n:{enumerable:!0,get:function(){return t[o]}})}})),e.default=t,Object.freeze(e)}var o=e(t);function n(t,e="text"){const n=o.sort(t.selectAll(e),(t=>+t.getAttribute("data-priority"))).reverse().map((t=>{const{x:e,y:o,width:n,height:r}=t.getBoundingClientRect();return{node:t,x:e,y:o,width:n,height:r}})),r=[];for(const t of n){const e=r.some((e=>{return n=e,!((o=t).x+o.width<n.x||n.x+n.width<o.x||o.y+o.height<n.y||n.y+n.height<o.y);var o,n}));o.select(t.node).classed("occluded",e),e||r.push(t)}return r}return function(t,{x:e=t=>t[0],y:r=t=>t[1],r:l=()=>3,label:a=(t,e)=>e,fill:s="#333",stroke:i="white",threshold:c=2e3,width:d=null,height:h=null,target:f=null,font:u="10px sans-serif",hover:g=!0,onHover:p=t=>t,hoverFont:y="bolder 12px sans-serif",labelsInCentroids:b=!0,backgroundFill:m="#fefefe01",strokeWidth:v=5,showVoronoi:x=!1,voronoiStroke:w="#ccc",showAnchors:k=!1,anchorsStroke:A="orange",anchorsFill:L="none",useOcclusion:j=!0,occludedStyle:C="opacity: 0.2",showPoints:P=!1,pointsFill:S="#ccc",pointsSelectedFill:M="firebrick",pointsStroke:O="#ccc",renderer:T="svg",debug:$=!1,selected:E=null,padding:F=3}={}){t=t.filter(((t,o)=>void 0!==e(t,o)&&null!==e(t,o)&&void 0!==r(t,o)&&null!==r(t,o)));let I=o.extent(t,e),B=o.extent(t,r);if(d=d||I[1]-I[0],h=h||B[1]-B[0],$&&console.log("✅ smartLabels renderer",T),f&&!function(t,e){return t&&t.node&&(t=t.node()),"canvas"===e.toLocaleLowerCase()?t instanceof HTMLCanvasElement:t instanceof SVGElement}(f,T))throw $&&console.log("❌ smartLabels Target doesn't match the renderer",f,T),new Error("Smartlabels Target doesn't match the renderer",f,T);"canvas"===T.toLocaleLowerCase()?(f=f?o.select(f.node?f.node():f):o.create("canvas").attr("width",d).attr("height",h),j=!1):f=(f=f?o.select(f.node?f.node():f):o.create("svg").attr("viewBox",[0,0,d,h]))||o.create("svg").attr("viewBox",[0,0,d,h]);const V=o.Delaunay.from(t,e,r),D=V.voronoi([I[0]-1,B[0]-1,I[1]+1,B[1]+1]);let H=t.map(((t,e)=>[t,D.cellPolygon(e)]));function R(t,o,n){if(!o){const l=V.find(e(t,n),r(t,n));if(-1===l)return console.log("couldn't find cell",l,t,e(t,n),r(t,n)),null;o=H[l][1]}return o}function W(n){$&&console.log("renderCanvasAnchorsVoronoiPoints","showAnchors",k,"labelsInCentroids",b,"showVoronoi",x,"showPoints",P),(k||b)&&H.forEach((({d:t,cell:l,show:a},s)=>{if(!a)return;const[i,c]=o.polygonCentroid(l);n.beginPath(),n.moveTo(i,c),n.lineTo(e(t,s),r(t,s)),n.strokeStyle=A,n.stroke()})),x&&(n.beginPath(),D.render(n),n.strokeStyle=w,n.stroke()),P&&(n.beginPath(),n.save(),n.globalAlpha=.7,n.fillStyle="black",t.forEach(((t,o)=>{n.beginPath(),n.arc(e(t,o),r(t,o),l(t,o),0,2*Math.PI)})),n.restore(),n.fillStyle=S,n.fill(),n.strokeStyle=O,n.stroke())}function q(t,n){H.forEach((({d:c,cell:d,show:h},f)=>{if(!h&&f!==n)return;const[g,p]=o.polygonCentroid(d),m=(Math.round(Math.atan2(p-r(c,f),g-e(c,f))/Math.PI*2)+4)%4;t.save();const x=l(c,f)+F,w=0===m?x:2===m?-x:x,k=1===m?x:3===m?-x:x;t.translate(b?g:e(c,f)+w,b?p:r(c,f)+k),t.textAlign=0===m?"left":1===m?"center":2===m?"right":"center",t.textBaseline=0===m?"middle":1===m?"top":2===m?"middle":"bottom",t.font=f===n?y:u,t.fillStyle=s,t.strokeStyle=i,t.lineWidth=v,t.strokeText(a(c,f),0,0),t.fillText(a(c,f),0,0),t.restore()}))}return H=H.map((([t,e],o)=>[t,R(t,e,o)])).map((([t,e])=>({d:t,cell:e,show:-o.polygonArea(e)>c}))),"canvas"===T.toLocaleLowerCase()?function(){const t=f.node().getContext("2d");W(t),q(t,E),g&&($&&console.log("setting onmousemove",f),f.on("mousemove",(e=>{const[n,r]=o.pointer(e),l=V.find(n,r);l!==E&&(E=l,t.clearRect(0,0,d,h),W(t),p&&"function"==typeof p&&p(l),q(t,E))})))}():function(){let t=null;j&&f.selectAll("style.smartLabels").data([0]).join("style").attr("class","smartLabels").html(`\n svg g.labels > text.occluded:not(.selected) { ${C} }\n `);const c=f.selectAll("rect.smartLabels").data([0]).join("rect").attr("class","smartLabels").attr("width",d).attr("height",h).attr("fill",m).attr("stroke","none"),T={top:t=>t.attr("text-anchor","middle").attr("y",((t,e)=>-(l(t,e)+F))),right:t=>t.attr("text-anchor","start").attr("dy","0.35em").attr("x",((t,e)=>l(t,e)+F)),bottom:t=>t.attr("text-anchor","middle").attr("dy","0.71em").attr("y",((t,e)=>l(t,e)+F)),left:t=>t.attr("text-anchor","end").attr("dy","0.35em").attr("x",((t,e)=>-(l(t,e)+F)))};let $;(k||b)&&(t=f.selectAll("g#anchors").data([H]).join("g").attr("pointer-events","none").attr("id","anchors").attr("stroke",A).attr("fill",L).selectAll("path.anchor").data((t=>t)).join("path").attr("class","anchor").attr("display",(({show:t})=>t?null:"none")).attr("d",(({d:t,cell:n},l)=>n?`M${o.polygonCentroid(n)}L${e(t,l)},${r(t,l)}`:null))),x&&f.selectAll("path#voronoi").data([1]).join("path").attr("id","voronoi").attr("stroke",w).attr("pointer-events","none").attr("d",D.render()),P&&($=f.selectAll("circle#points").data([1]).join("path").attr("id","points").attr("pointer-events","none").attr("stroke",O).attr("fill",S).attr("d",V.renderPoints(null,2)));const E=f.selectAll("g.labels").data([H]).join("g").attr("class","labels").attr("fill",s).attr("stroke",i).attr("stroke-width",v).attr("paint-order","stroke").attr("pointer-events","none").selectAll("text").data((t=>t)).join("text").style("font",u).each((function({d:t,cell:n},l){if(!n)return;const[a,s]=o.polygonCentroid(n),i=(Math.round(Math.atan2(s-r(t,l),a-e(t,l))/Math.PI*2)+4)%4;o.select(this).call(0===i?T.right:3===i?T.top:1===i?T.bottom:T.left)})).attr("transform",(({d:t,cell:n},l)=>{if(b){const[t,e]=o.polygonCentroid(n);return`translate(${t}, ${e})`}return`translate(${e(t,l)}, ${r(t,l)})`})).attr("display",(({show:t})=>t?null:"none")).text((({d:t},e,o)=>a(t,e,o)));let I=null;if(g&&c.on("mousemove",(e=>{const n=V.find(...o.pointer(e));n!==I&&(I=n,E.attr("display",(({show:t},e)=>e===I||t?null:"none")).style("font",((t,e)=>e===I?y:u)).classed("selected",((t,e)=>e===I)).filter(((t,e)=>e===I)).raise(),t&&t.attr("display",(({show:t},e)=>t||e===I?null:"none")),P&&$.attr("fill",((t,e)=>M)).classed("selected",((t,e)=>e===I)),p&&"function"==typeof p&&p(n))})),j){const t=new MutationObserver((function(){document.contains(f.node())&&(f.call(n,"g.labels > text"),t.disconnect())}));t.observe(document,{attributes:!1,childList:!0,characterData:!1,subtree:!0})}}(),Object.assign(f.node(),{delaunay:V,voronoi:D,cells:H})}}));
1
+ // smart-labels v0.0.6 Copyright (c) 2024 John Alexis Guerra Gómez
2
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("d3")):"function"==typeof define&&define.amd?define(["d3"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).smartLabels=e(t.d3)}(this,(function(t){"use strict";function e(t){var e=Object.create(null);return t&&Object.keys(t).forEach((function(o){if("default"!==o){var n=Object.getOwnPropertyDescriptor(t,o);Object.defineProperty(e,o,n.get?n:{enumerable:!0,get:function(){return t[o]}})}})),e.default=t,Object.freeze(e)}var o=e(t);function n(t,e="text"){const n=o.sort(t.selectAll(e),(t=>+t.getAttribute("data-priority"))).reverse().map((t=>{const{x:e,y:o,width:n,height:r}=t.getBoundingClientRect();return{node:t,x:e,y:o,width:n,height:r}})),r=[];for(const t of n){const e=r.some((e=>{return n=e,!((o=t).x+o.width<n.x||n.x+n.width<o.x||o.y+o.height<n.y||n.y+n.height<o.y);var o,n}));o.select(t.node).classed("occluded",e),e||r.push(t)}return r}return function(t,{x:e=t=>t[0],y:r=t=>t[1],r:l=()=>3,label:a=(t,e)=>e,fill:s="#333",stroke:i="white",width:c=null,height:d=null,target:h=null,renderer:f="svg",font:u=()=>"18px sans-serif",hover:g=!0,onHover:p=t=>t,hoverFont:y=()=>"bolder 12px sans-serif",labelsInCentroids:b=!0,threshold:m=2e3,alwaysShow:v=t=>!1,showLabel:w=(t,e)=>v(t)||-o.polygonArea(e)>m,backgroundFill:x="#fefefe01",strokeWidth:k=5,showVoronoi:L=!1,voronoiStroke:A="#ccc",showAnchors:j=!1,anchorsStroke:S="orange",anchorsFill:C="none",useOcclusion:P=!0,occludedStyle:M="opacity: 0.2",showPoints:O=!1,pointsFill:T="#ccc",pointsSelectedFill:$="firebrick",pointsStroke:E="#ccc",debug:F=!1,selected:I=null,padding:B=3}={}){t=t.filter(((t,o)=>void 0!==e(t,o)&&null!==e(t,o)&&void 0!==r(t,o)&&null!==r(t,o))),"string"==typeof u&&(u=()=>u),"string"==typeof y&&(y=()=>y);let V=o.extent(t,e),D=o.extent(t,r);if(c=c||V[1]-V[0],d=d||D[1]-D[0],F&&console.log("✅ smartLabels renderer",f),h&&!function(t,e){return t&&t.node&&(t=t.node()),"canvas"===e.toLocaleLowerCase()?t instanceof HTMLCanvasElement:t instanceof SVGElement}(h,f))throw F&&console.log("❌ smartLabels Target doesn't match the renderer",h,f),new Error("Smartlabels Target doesn't match the renderer",h,f);"canvas"===f.toLocaleLowerCase()?(h=h?o.select(h.node?h.node():h):o.create("canvas").attr("width",c).attr("height",d),P=!1):h=(h=h?o.select(h.node?h.node():h):o.create("svg").attr("viewBox",[0,0,c,d]))||o.create("svg").attr("viewBox",[0,0,c,d]);const H=o.Delaunay.from(t,e,r),R=H.voronoi([V[0]-1,D[0]-1,V[1]+1,D[1]+1]);let W=t.map(((t,e)=>[t,R.cellPolygon(e)]));function q(t,o,n){if(!o){const l=H.find(e(t,n),r(t,n));if(-1===l)return console.log("couldn't find cell",l,t,e(t,n),r(t,n)),null;o=W[l][1]}return o}function z(n){F&&console.log("renderCanvasAnchorsVoronoiPoints","showAnchors",j,"labelsInCentroids",b,"showVoronoi",L,"showPoints",O),(j||b)&&W.forEach((({d:t,cell:l,show:a},s)=>{if(!a)return;const[i,c]=o.polygonCentroid(l);n.beginPath(),n.moveTo(i,c),n.lineTo(e(t,s),r(t,s)),n.strokeStyle=S,n.stroke()})),L&&(n.beginPath(),R.render(n),n.strokeStyle=A,n.stroke()),O&&(n.beginPath(),n.save(),n.globalAlpha=.7,n.fillStyle="black",t.forEach(((t,o)=>{n.beginPath(),n.arc(e(t,o),r(t,o),l(t,o),0,2*Math.PI)})),n.restore(),n.fillStyle=T,n.fill(),n.strokeStyle=E,n.stroke())}function G(t,n){W.forEach((({d:c,cell:d,show:h},f)=>{if(!h&&f!==n)return;const[g,p]=o.polygonCentroid(d),m=(Math.round(Math.atan2(p-r(c,f),g-e(c,f))/Math.PI*2)+4)%4;t.save();const v=l(c,f)+B,w=0===m?v:2===m?-v:v,x=1===m?v:3===m?-v:v;t.translate(b?g:e(c,f)+w,b?p:r(c,f)+x),t.textAlign=0===m?"left":1===m?"center":2===m?"right":"center",t.textBaseline=0===m?"middle":1===m?"top":2===m?"middle":"bottom",t.font=f===n?y(c,f):u(c,f),t.fillStyle=s,t.strokeStyle=i,t.lineWidth=k,t.strokeText(a(c,f),0,0),t.fillText(a(c,f),0,0),t.restore()}))}return W=W.map((([t,e],o)=>[t,q(t,e,o)])).map((([t,e])=>({d:t,cell:e,show:w(t,e)}))),"canvas"===f.toLocaleLowerCase()?function(){const t=h.node().getContext("2d");z(t),G(t,I),g&&(F&&console.log("setting onmousemove",h),h.on("mousemove",(e=>{const[n,r]=o.pointer(e),l=H.find(n,r);l!==I&&(I=l,t.clearRect(0,0,c,d),z(t),p&&"function"==typeof p&&p(l),G(t,I))})))}():function(){let t=null;P&&h.selectAll("style.smartLabels").data([0]).join("style").attr("class","smartLabels").html(`\n svg g.labels > text.occluded:not(.selected) { ${M} }\n `);const f=h.selectAll("rect.smartLabels").data([0]).join("rect").attr("class","smartLabels").attr("width",c).attr("height",d).attr("fill",x).attr("stroke","none"),m={top:t=>t.attr("text-anchor","middle").attr("y",((t,e)=>-(l(t,e)+B))),right:t=>t.attr("text-anchor","start").attr("dy","0.35em").attr("x",((t,e)=>l(t,e)+B)),bottom:t=>t.attr("text-anchor","middle").attr("dy","0.71em").attr("y",((t,e)=>l(t,e)+B)),left:t=>t.attr("text-anchor","end").attr("dy","0.35em").attr("x",((t,e)=>-(l(t,e)+B)))};let v;(j||b)&&(t=h.selectAll("g#anchors").data([W]).join("g").attr("pointer-events","none").attr("id","anchors").attr("stroke",S).attr("fill",C).selectAll("path.anchor").data((t=>t)).join("path").attr("class","anchor").attr("display",(({show:t})=>t?null:"none")).attr("d",(({d:t,cell:n},l)=>n?`M${o.polygonCentroid(n)}L${e(t,l)},${r(t,l)}`:null))),L&&h.selectAll("path#voronoi").data([1]).join("path").attr("id","voronoi").attr("stroke",A).attr("pointer-events","none").attr("d",R.render()),O&&(v=h.selectAll("circle#points").data([1]).join("path").attr("id","points").attr("pointer-events","none").attr("stroke",E).attr("fill",T).attr("d",H.renderPoints(null,2)));const w=h.selectAll("g.labels").data([W]).join("g").attr("class","labels").attr("fill",s).attr("stroke",i).attr("stroke-width",k).attr("paint-order","stroke").attr("pointer-events","none").selectAll("text").data((t=>t)).join("text").style("font",(({d:t},e)=>u(t,e))).each((function({d:t,cell:n},l){if(!n)return;const[a,s]=o.polygonCentroid(n),i=(Math.round(Math.atan2(s-r(t,l),a-e(t,l))/Math.PI*2)+4)%4;o.select(this).call(0===i?m.right:3===i?m.top:1===i?m.bottom:m.left)})).attr("transform",(({d:t,cell:n},l)=>{if(b){const[t,e]=o.polygonCentroid(n);return`translate(${t}, ${e})`}return`translate(${e(t,l)}, ${r(t,l)})`})).attr("display",(({show:t})=>t?null:"none")).text((({d:t},e,o)=>a(t,e,o)));let F=null;if(g&&f.on("mousemove",(e=>{const n=H.find(...o.pointer(e));n!==F&&(F=n,w.attr("display",(({show:t},e)=>e===F||t?null:"none")).style("font",(({d:t},e)=>e===F?y(t,e):u(t,e))).classed("selected",((t,e)=>e===F)).filter(((t,e)=>e===F)).raise(),t&&t.attr("display",(({show:t},e)=>t||e===F?null:"none")),O&&v.attr("fill",((t,e)=>$)).classed("selected",((t,e)=>e===F)),p&&"function"==typeof p&&p(n))})),P){const t=new MutationObserver((function(){document.contains(h.node())&&(h.call(n,"g.labels > text"),t.disconnect())}));t.observe(document,{attributes:!1,childList:!0,characterData:!1,subtree:!0})}}(),Object.assign(h.node(),{delaunay:H,voronoi:R,cells:W})}}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-labels",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "A basic library for adding labels to a visualization when there is enough space",
5
5
  "type": "module",
6
6
  "main": "dist/smartLabels.min.js",
package/src/index.js CHANGED
@@ -24,15 +24,18 @@ export default function smartLabels(
24
24
  label = (d, i) => i, // Accessor for the label
25
25
  fill = "#333", // label fill color
26
26
  stroke = "white", // label stroke color
27
- threshold = 2000, // Areas over this size would get labels
28
27
  width = null,
29
28
  height = null,
30
29
  target = null, // Where do you want it to draw
31
- font = "10px sans-serif",
30
+ renderer = "svg", // canvas or svg
31
+ font = () => "18px sans-serif",
32
32
  hover = true, // Show label of the hovered point
33
33
  onHover = (i) => i, // callback when hovered, will pass the index of the selected element
34
- hoverFont = "bolder 12px sans-serif",
34
+ hoverFont = () => "bolder 12px sans-serif",
35
35
  labelsInCentroids = true,
36
+ threshold = 2000, // Areas over this size would get labels
37
+ alwaysShow = (d) => false, // If returns true for the node, it will always show the label
38
+ showLabel = (d, cell) => alwaysShow(d) || -d3.polygonArea(cell) > threshold, // If true, show the label
36
39
 
37
40
  backgroundFill = "#fefefe01", // What to paint the bg rect of the labels. Needed for the onHover
38
41
  strokeWidth = 5,
@@ -52,7 +55,6 @@ export default function smartLabels(
52
55
  pointsFill = "#ccc",
53
56
  pointsSelectedFill = "firebrick",
54
57
  pointsStroke = "#ccc",
55
- renderer = "svg",
56
58
  debug = false,
57
59
  selected = null,
58
60
  padding = 3, // label padding in pixels
@@ -66,6 +68,9 @@ export default function smartLabels(
66
68
  y(d, index) !== null
67
69
  );
68
70
 
71
+ if (typeof font === "string") font = () => font;
72
+ if (typeof hoverFont === "string") hoverFont = () => hoverFont;
73
+
69
74
  let xExtent = d3.extent(data, x),
70
75
  yExtent = d3.extent(data, y);
71
76
  width = width || xExtent[1] - xExtent[0];
@@ -85,8 +90,17 @@ export default function smartLabels(
85
90
  }
86
91
 
87
92
  if (target && !checkIfTargetMatchesRenderer(target, renderer)) {
88
- if (debug) console.log("❌ smartLabels Target doesn't match the renderer", target, renderer);
89
- throw new Error("Smartlabels Target doesn't match the renderer", target, renderer);
93
+ if (debug)
94
+ console.log(
95
+ "❌ smartLabels Target doesn't match the renderer",
96
+ target,
97
+ renderer
98
+ );
99
+ throw new Error(
100
+ "Smartlabels Target doesn't match the renderer",
101
+ target,
102
+ renderer
103
+ );
90
104
  }
91
105
 
92
106
  // Try to reuse the target
@@ -118,7 +132,7 @@ export default function smartLabels(
118
132
  // Replace null cells with the nearest one
119
133
  cells = cells
120
134
  .map(([d, cell], index) => [d, getNearestCell(d, cell, index)])
121
- .map(([d, cell]) => ({ d, cell, show: -d3.polygonArea(cell) > threshold }));
135
+ .map(([d, cell]) => ({ d, cell, show: showLabel(d, cell) }));
122
136
 
123
137
  // cells can be null when we have duplicated coords
124
138
  // https://github.com/d3/d3-delaunay/issues/106
@@ -237,7 +251,7 @@ export default function smartLabels(
237
251
  .selectAll("text")
238
252
  .data((d) => d)
239
253
  .join("text")
240
- .style("font", font)
254
+ .style("font", ({d}, i) => font(d, i))
241
255
  .each(function ({ d, cell }, index) {
242
256
  if (!cell) return;
243
257
  const [cx, cy] = d3.polygonCentroid(cell);
@@ -283,7 +297,9 @@ export default function smartLabels(
283
297
  .attr("display", ({ show }, i) =>
284
298
  i === selected || show ? null : "none"
285
299
  )
286
- .style("font", (_, i) => (i === selected ? hoverFont : font))
300
+ .style("font", ({d}, i) =>
301
+ i === selected ? hoverFont(d, i) : font(d, i)
302
+ )
287
303
  .classed("selected", (_, i) => i === selected)
288
304
  .filter((_, i) => i === selected)
289
305
  .raise();
@@ -409,7 +425,7 @@ export default function smartLabels(
409
425
  : angle === 2
410
426
  ? "middle"
411
427
  : "bottom";
412
- context.font = index === selected ? hoverFont : font;
428
+ context.font = index === selected ? hoverFont(d, index) : font(d, index);
413
429
  context.fillStyle = fill;
414
430
  context.strokeStyle = stroke;
415
431
  context.lineWidth = strokeWidth;