@flourish/pocket-knife
Advanced tools
Comparing version
{ | ||
"extends": "../../.eslintrc.cjs" | ||
"extends": "../../.eslintrc.cjs", | ||
"ignorePatterns": [ | ||
"pocket_knife.js", | ||
"pocket_knife.min.js" | ||
] | ||
} |
{ | ||
"name": "@flourish/pocket-knife", | ||
"type": "module", | ||
"version": "1.3.0", | ||
"version": "1.3.2", | ||
"private": false, | ||
@@ -24,3 +24,6 @@ "description": "Flourish module with handy tools", | ||
"devDependencies": { | ||
"@babel/core": "^7.23.6", | ||
"@babel/preset-env": "^7.23.6", | ||
"@flourish/eslint-plugin-flourish": "^0.7.2", | ||
"@rollup/plugin-babel": "^6.0.4", | ||
"@types/mocha": "^10.0.1", | ||
@@ -32,4 +35,4 @@ "canvas": "^2.11.0", | ||
"mocha": "^10.2.0", | ||
"rollup": "^1.1.2", | ||
"rollup-plugin-node-resolve": "^4.0.0", | ||
"rollup": "^4.6.1", | ||
"@rollup/plugin-node-resolve": "^15.2.3", | ||
"uglify-js": "^3.4.9" | ||
@@ -43,7 +46,7 @@ }, | ||
"d3-collection": "^1.0.7", | ||
"d3-color": "^1.4.0" | ||
"d3-color": "^3.1.0" | ||
}, | ||
"scripts": { | ||
"test": "t(){ mocha ${1:-src}; }; t", | ||
"lint": "eslint src", | ||
"lint": "eslint .", | ||
"build": "rollup -c", | ||
@@ -50,0 +53,0 @@ "minify": "uglifyjs -m -o pocket_knife.min.js pocket_knife.js", |
(function (global, factory) { | ||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : | ||
typeof define === 'function' && define.amd ? define(['exports'], factory) : | ||
(global = global || self, factory(global.pocketKnife = {})); | ||
}(this, (function (exports) { 'use strict'; | ||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.pocketKnife = {})); | ||
})(this, (function (exports) { 'use strict'; | ||
@@ -24,11 +24,11 @@ function define(constructor, factory, prototype) { | ||
var reI = "\\s*([+-]?\\d+)\\s*", | ||
reN = "\\s*([+-]?\\d*\\.?\\d+(?:[eE][+-]?\\d+)?)\\s*", | ||
reP = "\\s*([+-]?\\d*\\.?\\d+(?:[eE][+-]?\\d+)?)%\\s*", | ||
reN = "\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*", | ||
reP = "\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*", | ||
reHex = /^#([0-9a-f]{3,8})$/, | ||
reRgbInteger = new RegExp("^rgb\\(" + [reI, reI, reI] + "\\)$"), | ||
reRgbPercent = new RegExp("^rgb\\(" + [reP, reP, reP] + "\\)$"), | ||
reRgbaInteger = new RegExp("^rgba\\(" + [reI, reI, reI, reN] + "\\)$"), | ||
reRgbaPercent = new RegExp("^rgba\\(" + [reP, reP, reP, reN] + "\\)$"), | ||
reHslPercent = new RegExp("^hsl\\(" + [reN, reP, reP] + "\\)$"), | ||
reHslaPercent = new RegExp("^hsla\\(" + [reN, reP, reP, reN] + "\\)$"); | ||
reRgbInteger = new RegExp(`^rgb\\(${reI},${reI},${reI}\\)$`), | ||
reRgbPercent = new RegExp(`^rgb\\(${reP},${reP},${reP}\\)$`), | ||
reRgbaInteger = new RegExp(`^rgba\\(${reI},${reI},${reI},${reN}\\)$`), | ||
reRgbaPercent = new RegExp(`^rgba\\(${reP},${reP},${reP},${reN}\\)$`), | ||
reHslPercent = new RegExp(`^hsl\\(${reN},${reP},${reP}\\)$`), | ||
reHslaPercent = new RegExp(`^hsla\\(${reN},${reP},${reP},${reN}\\)$`); | ||
@@ -187,6 +187,6 @@ var named = { | ||
define(Color, color, { | ||
copy: function(channels) { | ||
copy(channels) { | ||
return Object.assign(new this.constructor, this, channels); | ||
}, | ||
displayable: function() { | ||
displayable() { | ||
return this.rgb().displayable(); | ||
@@ -196,2 +196,3 @@ }, | ||
formatHex: color_formatHex, | ||
formatHex8: color_formatHex8, | ||
formatHsl: color_formatHsl, | ||
@@ -206,2 +207,6 @@ formatRgb: color_formatRgb, | ||
function color_formatHex8() { | ||
return this.rgb().formatHex8(); | ||
} | ||
function color_formatHsl() { | ||
@@ -262,14 +267,17 @@ return hslConvert(this).formatHsl(); | ||
define(Rgb, rgb, extend(Color, { | ||
brighter: function(k) { | ||
brighter(k) { | ||
k = k == null ? brighter : Math.pow(brighter, k); | ||
return new Rgb(this.r * k, this.g * k, this.b * k, this.opacity); | ||
}, | ||
darker: function(k) { | ||
darker(k) { | ||
k = k == null ? darker : Math.pow(darker, k); | ||
return new Rgb(this.r * k, this.g * k, this.b * k, this.opacity); | ||
}, | ||
rgb: function() { | ||
rgb() { | ||
return this; | ||
}, | ||
displayable: function() { | ||
clamp() { | ||
return new Rgb(clampi(this.r), clampi(this.g), clampi(this.b), clampa(this.opacity)); | ||
}, | ||
displayable() { | ||
return (-0.5 <= this.r && this.r < 255.5) | ||
@@ -282,2 +290,3 @@ && (-0.5 <= this.g && this.g < 255.5) | ||
formatHex: rgb_formatHex, | ||
formatHex8: rgb_formatHex8, | ||
formatRgb: rgb_formatRgb, | ||
@@ -288,16 +297,24 @@ toString: rgb_formatRgb | ||
function rgb_formatHex() { | ||
return "#" + hex(this.r) + hex(this.g) + hex(this.b); | ||
return `#${hex(this.r)}${hex(this.g)}${hex(this.b)}`; | ||
} | ||
function rgb_formatHex8() { | ||
return `#${hex(this.r)}${hex(this.g)}${hex(this.b)}${hex((isNaN(this.opacity) ? 1 : this.opacity) * 255)}`; | ||
} | ||
function rgb_formatRgb() { | ||
var a = this.opacity; a = isNaN(a) ? 1 : Math.max(0, Math.min(1, a)); | ||
return (a === 1 ? "rgb(" : "rgba(") | ||
+ Math.max(0, Math.min(255, Math.round(this.r) || 0)) + ", " | ||
+ Math.max(0, Math.min(255, Math.round(this.g) || 0)) + ", " | ||
+ Math.max(0, Math.min(255, Math.round(this.b) || 0)) | ||
+ (a === 1 ? ")" : ", " + a + ")"); | ||
const a = clampa(this.opacity); | ||
return `${a === 1 ? "rgb(" : "rgba("}${clampi(this.r)}, ${clampi(this.g)}, ${clampi(this.b)}${a === 1 ? ")" : `, ${a})`}`; | ||
} | ||
function clampa(opacity) { | ||
return isNaN(opacity) ? 1 : Math.max(0, Math.min(1, opacity)); | ||
} | ||
function clampi(value) { | ||
return Math.max(0, Math.min(255, Math.round(value) || 0)); | ||
} | ||
function hex(value) { | ||
value = Math.max(0, Math.min(255, Math.round(value) || 0)); | ||
value = clampi(value); | ||
return (value < 16 ? "0" : "") + value.toString(16); | ||
@@ -351,11 +368,11 @@ } | ||
define(Hsl, hsl, extend(Color, { | ||
brighter: function(k) { | ||
brighter(k) { | ||
k = k == null ? brighter : Math.pow(brighter, k); | ||
return new Hsl(this.h, this.s, this.l * k, this.opacity); | ||
}, | ||
darker: function(k) { | ||
darker(k) { | ||
k = k == null ? darker : Math.pow(darker, k); | ||
return new Hsl(this.h, this.s, this.l * k, this.opacity); | ||
}, | ||
rgb: function() { | ||
rgb() { | ||
var h = this.h % 360 + (this.h < 0) * 360, | ||
@@ -373,3 +390,6 @@ s = isNaN(h) || isNaN(this.s) ? 0 : this.s, | ||
}, | ||
displayable: function() { | ||
clamp() { | ||
return new Hsl(clamph(this.h), clampt(this.s), clampt(this.l), clampa(this.opacity)); | ||
}, | ||
displayable() { | ||
return (0 <= this.s && this.s <= 1 || isNaN(this.s)) | ||
@@ -379,12 +399,17 @@ && (0 <= this.l && this.l <= 1) | ||
}, | ||
formatHsl: function() { | ||
var a = this.opacity; a = isNaN(a) ? 1 : Math.max(0, Math.min(1, a)); | ||
return (a === 1 ? "hsl(" : "hsla(") | ||
+ (this.h || 0) + ", " | ||
+ (this.s || 0) * 100 + "%, " | ||
+ (this.l || 0) * 100 + "%" | ||
+ (a === 1 ? ")" : ", " + a + ")"); | ||
formatHsl() { | ||
const a = clampa(this.opacity); | ||
return `${a === 1 ? "hsl(" : "hsla("}${clamph(this.h)}, ${clampt(this.s) * 100}%, ${clampt(this.l) * 100}%${a === 1 ? ")" : `, ${a})`}`; | ||
} | ||
})); | ||
function clamph(value) { | ||
value = (value || 0) % 360; | ||
return value < 0 ? value + 360 : value; | ||
} | ||
function clampt(value) { | ||
return Math.max(0, Math.min(1, value || 0)); | ||
} | ||
/* From FvD 13.37, CSS Color Module Level 3 */ | ||
@@ -398,175 +423,2 @@ function hsl2rgb(h, m1, m2) { | ||
var deg2rad = Math.PI / 180; | ||
var rad2deg = 180 / Math.PI; | ||
// https://observablehq.com/@mbostock/lab-and-rgb | ||
var K = 18, | ||
Xn = 0.96422, | ||
Yn = 1, | ||
Zn = 0.82521, | ||
t0 = 4 / 29, | ||
t1 = 6 / 29, | ||
t2 = 3 * t1 * t1, | ||
t3 = t1 * t1 * t1; | ||
function labConvert(o) { | ||
if (o instanceof Lab) return new Lab(o.l, o.a, o.b, o.opacity); | ||
if (o instanceof Hcl) return hcl2lab(o); | ||
if (!(o instanceof Rgb)) o = rgbConvert(o); | ||
var r = rgb2lrgb(o.r), | ||
g = rgb2lrgb(o.g), | ||
b = rgb2lrgb(o.b), | ||
y = xyz2lab((0.2225045 * r + 0.7168786 * g + 0.0606169 * b) / Yn), x, z; | ||
if (r === g && g === b) x = z = y; else { | ||
x = xyz2lab((0.4360747 * r + 0.3850649 * g + 0.1430804 * b) / Xn); | ||
z = xyz2lab((0.0139322 * r + 0.0971045 * g + 0.7141733 * b) / Zn); | ||
} | ||
return new Lab(116 * y - 16, 500 * (x - y), 200 * (y - z), o.opacity); | ||
} | ||
function lab(l, a, b, opacity) { | ||
return arguments.length === 1 ? labConvert(l) : new Lab(l, a, b, opacity == null ? 1 : opacity); | ||
} | ||
function Lab(l, a, b, opacity) { | ||
this.l = +l; | ||
this.a = +a; | ||
this.b = +b; | ||
this.opacity = +opacity; | ||
} | ||
define(Lab, lab, extend(Color, { | ||
brighter: function(k) { | ||
return new Lab(this.l + K * (k == null ? 1 : k), this.a, this.b, this.opacity); | ||
}, | ||
darker: function(k) { | ||
return new Lab(this.l - K * (k == null ? 1 : k), this.a, this.b, this.opacity); | ||
}, | ||
rgb: function() { | ||
var y = (this.l + 16) / 116, | ||
x = isNaN(this.a) ? y : y + this.a / 500, | ||
z = isNaN(this.b) ? y : y - this.b / 200; | ||
x = Xn * lab2xyz(x); | ||
y = Yn * lab2xyz(y); | ||
z = Zn * lab2xyz(z); | ||
return new Rgb( | ||
lrgb2rgb( 3.1338561 * x - 1.6168667 * y - 0.4906146 * z), | ||
lrgb2rgb(-0.9787684 * x + 1.9161415 * y + 0.0334540 * z), | ||
lrgb2rgb( 0.0719453 * x - 0.2289914 * y + 1.4052427 * z), | ||
this.opacity | ||
); | ||
} | ||
})); | ||
function xyz2lab(t) { | ||
return t > t3 ? Math.pow(t, 1 / 3) : t / t2 + t0; | ||
} | ||
function lab2xyz(t) { | ||
return t > t1 ? t * t * t : t2 * (t - t0); | ||
} | ||
function lrgb2rgb(x) { | ||
return 255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055); | ||
} | ||
function rgb2lrgb(x) { | ||
return (x /= 255) <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); | ||
} | ||
function hclConvert(o) { | ||
if (o instanceof Hcl) return new Hcl(o.h, o.c, o.l, o.opacity); | ||
if (!(o instanceof Lab)) o = labConvert(o); | ||
if (o.a === 0 && o.b === 0) return new Hcl(NaN, 0 < o.l && o.l < 100 ? 0 : NaN, o.l, o.opacity); | ||
var h = Math.atan2(o.b, o.a) * rad2deg; | ||
return new Hcl(h < 0 ? h + 360 : h, Math.sqrt(o.a * o.a + o.b * o.b), o.l, o.opacity); | ||
} | ||
function hcl(h, c, l, opacity) { | ||
return arguments.length === 1 ? hclConvert(h) : new Hcl(h, c, l, opacity == null ? 1 : opacity); | ||
} | ||
function Hcl(h, c, l, opacity) { | ||
this.h = +h; | ||
this.c = +c; | ||
this.l = +l; | ||
this.opacity = +opacity; | ||
} | ||
function hcl2lab(o) { | ||
if (isNaN(o.h)) return new Lab(o.l, 0, 0, o.opacity); | ||
var h = o.h * deg2rad; | ||
return new Lab(o.l, Math.cos(h) * o.c, Math.sin(h) * o.c, o.opacity); | ||
} | ||
define(Hcl, hcl, extend(Color, { | ||
brighter: function(k) { | ||
return new Hcl(this.h, this.c, this.l + K * (k == null ? 1 : k), this.opacity); | ||
}, | ||
darker: function(k) { | ||
return new Hcl(this.h, this.c, this.l - K * (k == null ? 1 : k), this.opacity); | ||
}, | ||
rgb: function() { | ||
return hcl2lab(this).rgb(); | ||
} | ||
})); | ||
var A = -0.14861, | ||
B = +1.78277, | ||
C = -0.29227, | ||
D = -0.90649, | ||
E = +1.97294, | ||
ED = E * D, | ||
EB = E * B, | ||
BC_DA = B * C - D * A; | ||
function cubehelixConvert(o) { | ||
if (o instanceof Cubehelix) return new Cubehelix(o.h, o.s, o.l, o.opacity); | ||
if (!(o instanceof Rgb)) o = rgbConvert(o); | ||
var r = o.r / 255, | ||
g = o.g / 255, | ||
b = o.b / 255, | ||
l = (BC_DA * b + ED * r - EB * g) / (BC_DA + ED - EB), | ||
bl = b - l, | ||
k = (E * (g - l) - C * bl) / D, | ||
s = Math.sqrt(k * k + bl * bl) / (E * l * (1 - l)), // NaN if l=0 or l=1 | ||
h = s ? Math.atan2(k, bl) * rad2deg - 120 : NaN; | ||
return new Cubehelix(h < 0 ? h + 360 : h, s, l, o.opacity); | ||
} | ||
function cubehelix(h, s, l, opacity) { | ||
return arguments.length === 1 ? cubehelixConvert(h) : new Cubehelix(h, s, l, opacity == null ? 1 : opacity); | ||
} | ||
function Cubehelix(h, s, l, opacity) { | ||
this.h = +h; | ||
this.s = +s; | ||
this.l = +l; | ||
this.opacity = +opacity; | ||
} | ||
define(Cubehelix, cubehelix, extend(Color, { | ||
brighter: function(k) { | ||
k = k == null ? brighter : Math.pow(brighter, k); | ||
return new Cubehelix(this.h, this.s, this.l * k, this.opacity); | ||
}, | ||
darker: function(k) { | ||
k = k == null ? darker : Math.pow(darker, k); | ||
return new Cubehelix(this.h, this.s, this.l * k, this.opacity); | ||
}, | ||
rgb: function() { | ||
var h = isNaN(this.h) ? 0 : (this.h + 120) * deg2rad, | ||
l = +this.l, | ||
a = isNaN(this.s) ? 0 : this.s * l * (1 - l), | ||
cosh = Math.cos(h), | ||
sinh = Math.sin(h); | ||
return new Rgb( | ||
255 * (l + a * (A * cosh + B * sinh)), | ||
255 * (l + a * (C * cosh + D * sinh)), | ||
255 * (l + a * (E * cosh)), | ||
this.opacity | ||
); | ||
} | ||
})); | ||
var prefix = "$"; | ||
@@ -816,4 +668,4 @@ | ||
var canvas = document.createElement("canvas"); | ||
var ctx = canvas.getContext("2d"); | ||
const canvas = document.createElement("canvas"); | ||
const ctx = canvas.getContext("2d"); | ||
@@ -889,70 +741,130 @@ function remToPx(rem) { | ||
function wrapStringToLines(text, font_styles, text_max_lines, max_width) { | ||
var lines = []; | ||
var max_line_width = 0; | ||
function wrapStringToLines(text, font_styles, max_lines, max_width, allow_single_word_truncation = true) { | ||
if (typeof text !== "string") text = String(text); | ||
const isOverflow = string => ctx.measureText(string).width > max_width; | ||
const truncateStringToWidth = (string, width) => { | ||
let string_width = ctx.measureText(string).width; | ||
// Check if the string needs truncation | ||
if (string_width <= width) { | ||
return string; | ||
} | ||
// if no lines fit, return max_line_width of 0 (i.e. hide string) | ||
if (text_max_lines !== null && text_max_lines <= 0) { | ||
lines.widest_line = max_line_width; | ||
let remove_counter = 1; | ||
let truncated_string = string; | ||
do { | ||
truncated_string = string.substring(0, string.length - remove_counter) + "…"; | ||
string_width = ctx.measureText(truncated_string).width; | ||
} while ((string_width > width) && (++remove_counter < string.length)); | ||
if (truncated_string.length < 1) truncated_string = string.substring(0, 1) + "…"; | ||
return truncated_string; | ||
}; | ||
ctx.font = font_styles; | ||
let lines = []; | ||
Object.defineProperty(lines, "widest_line", { | ||
value: 0, | ||
enumerable: true, | ||
writable: true | ||
}); | ||
Object.defineProperty(lines, "has_truncated", { | ||
value: false, | ||
enumerable: true, | ||
writable: true | ||
}); | ||
if (typeof text === "undefined" || (Number.isInteger(max_lines) && max_lines <= 0)) return lines; | ||
if (text.length === 0) { | ||
lines.push(""); | ||
return lines; | ||
} | ||
if (max_width <= 0) { | ||
if (text.length > 0) { | ||
lines.push(text.slice(0, 1) + "…"); | ||
lines.widest_line = ctx.measureText(lines[0]).width; | ||
} | ||
return lines; | ||
} | ||
ctx.font = font_styles; | ||
if (!text || text.length === 0 || max_lines === 0 || max_width <= 0) return lines; | ||
const all_words = text.split(/\s+/g); | ||
// if everything fits in one line, leave as a single string | ||
var text_width = ctx.measureText(text).width; | ||
if (text_width <= max_width) { | ||
// Check if whole label fits ok - no work needed - return it | ||
if (!isOverflow(text)) { | ||
lines.push(text); | ||
max_line_width = text_width; | ||
lines.widest_line = ctx.measureText(text).width; | ||
lines.has_truncated = false; | ||
return lines; | ||
} | ||
// Otherwise… | ||
else { | ||
// Create array of words | ||
var words = text.trim().split(/\s+/g); | ||
// Loop through the words, adding them until they won't fit | ||
var current_line = ""; | ||
for (var i = 0; i < words.length; i++) { | ||
var word = words[i]; | ||
var string = current_line + (current_line ? " " : "") + word; | ||
var string_width = ctx.measureText(string).width; | ||
// If the string including the next word fits, update the | ||
// current_line and the max_width | ||
if (string_width <= max_width) { | ||
current_line = string; | ||
max_line_width = Math.max(max_line_width, string_width); | ||
} | ||
// If we reach this point, the text has overflowed, | ||
// so should always result in a truncation or a new line | ||
else { | ||
var last_line = lines.length + 1 == text_max_lines; | ||
if (!last_line && current_line) { | ||
lines.push(current_line); | ||
var word_width = ctx.measureText(word).width; | ||
if (word_width <= max_width) { | ||
current_line = word; | ||
max_line_width = Math.max(max_line_width, word_width); | ||
continue; | ||
} | ||
// Check if the first word doesn't even fit in the space - return truncated first word | ||
let first_word = new String(all_words[0]); | ||
if (first_word && isOverflow(first_word)) { | ||
if (allow_single_word_truncation) { | ||
first_word = truncateStringToWidth(first_word, max_width); | ||
} | ||
else if (all_words.length > 1) { | ||
first_word += "…"; | ||
} | ||
lines.push(first_word); | ||
lines.widest_line = ctx.measureText(first_word).width; | ||
lines.has_truncated = true; | ||
return lines; | ||
} | ||
// Fit words in line by line, truncating if necessary - return | ||
let currentLine = ""; | ||
let has_truncated = false; | ||
all_words.forEach(word => { | ||
if (has_truncated) return; | ||
if (isOverflow(currentLine + (currentLine ? " " : "") + word)) { | ||
if (currentLine) { | ||
const currentLineWidth = ctx.measureText(currentLine).width; | ||
if (currentLineWidth > max_width) { | ||
currentLine = truncateStringToWidth(currentLine, max_width); | ||
has_truncated = true; | ||
} | ||
// Truncate | ||
var remove_counter = 1; | ||
var truncated_string = current_line; | ||
do { | ||
truncated_string = current_line.substring(0, string.length - remove_counter) + "…"; | ||
string_width = ctx.measureText(truncated_string).width; | ||
} | ||
while ((string_width > max_width) && (++remove_counter < string.length)); | ||
max_line_width = Math.max(max_line_width, string_width); | ||
current_line = truncated_string; | ||
break; | ||
lines.push(currentLine); | ||
currentLine = ""; | ||
} | ||
currentLine = word; | ||
} | ||
lines.push(current_line); | ||
else { | ||
currentLine += (currentLine ? " " : "") + word; | ||
} | ||
}); | ||
if (currentLine && !has_truncated) { | ||
lines.push(truncateStringToWidth(currentLine, max_width)); | ||
} | ||
lines.widest_line = max_line_width; | ||
if (lines.length > max_lines) { | ||
// can't have more lines than max_lines, so slice and add ellipses. | ||
const to_truncate = lines.length > max_lines; | ||
if (to_truncate && Number.isInteger(max_lines)) { | ||
lines.splice(max_lines, lines.length - max_lines); | ||
let last_line = lines[lines.length - 1]; | ||
// check if adding an ellipsis will exceed max_width | ||
if (isOverflow(last_line + "…")) { | ||
// truncate further before adding ellipsis | ||
last_line = truncateStringToWidth(last_line, max_width - ctx.measureText("…").width); | ||
} | ||
// add ellipsis if not already present | ||
if (last_line.slice(-1) !== "…") { | ||
last_line += "…"; | ||
} | ||
lines[lines.length - 1] = last_line; | ||
has_truncated = true; | ||
} | ||
} | ||
// find the widest entry in lines and measure it | ||
lines.widest_line = Math.max(...lines.map(line => ctx.measureText(line).width)); | ||
lines.has_truncated = has_truncated; | ||
return lines; | ||
} | ||
function getUniqueValuesFromBinding(data, binding, index) { | ||
@@ -986,4 +898,2 @@ if (data.column_names[binding] === undefined) return []; | ||
Object.defineProperty(exports, '__esModule', { value: true }); | ||
}))); | ||
})); |
@@ -0,1 +1,7 @@ | ||
# 1.3.2 | ||
* Ensure non-string inputs to wrapStringToLines are forced into strings | ||
# 1.3.1 | ||
* Changed the wrapStringToLinesFunction to add new functionality but not effect on existing usage | ||
# 1.3.0 | ||
@@ -2,0 +8,0 @@ * Improve contrast checking in `isPale()` |
@@ -1,3 +0,6 @@ | ||
import nodeResolve from "rollup-plugin-node-resolve"; | ||
import nodeResolve from "@rollup/plugin-node-resolve"; | ||
import babel from "@rollup/plugin-babel"; | ||
const is_production = process.env.NODE_ENV === "production" || !!process.env.CI; | ||
export default { | ||
@@ -11,3 +14,4 @@ input: "src/index.js", | ||
plugins: [ | ||
nodeResolve() | ||
nodeResolve(), | ||
is_production && babel({ babelHelpers: "bundled", rootMode: "upward" }), | ||
], | ||
@@ -14,0 +18,0 @@ onwarn: function (warning, warn) { |
171
src/index.js
@@ -7,4 +7,4 @@ import { color } from "d3-color"; | ||
var canvas = document.createElement("canvas"); | ||
var ctx = canvas.getContext("2d"); | ||
const canvas = document.createElement("canvas"); | ||
const ctx = canvas.getContext("2d"); | ||
@@ -80,70 +80,130 @@ function remToPx(rem) { | ||
function wrapStringToLines(text, font_styles, text_max_lines, max_width) { | ||
var lines = []; | ||
var max_line_width = 0; | ||
function wrapStringToLines(text, font_styles, max_lines, max_width, allow_single_word_truncation = true) { | ||
if (typeof text !== "string") text = String(text); | ||
const isOverflow = string => ctx.measureText(string).width > max_width; | ||
const truncateStringToWidth = (string, width) => { | ||
let string_width = ctx.measureText(string).width; | ||
// Check if the string needs truncation | ||
if (string_width <= width) { | ||
return string; | ||
} | ||
// if no lines fit, return max_line_width of 0 (i.e. hide string) | ||
if (text_max_lines !== null && text_max_lines <= 0) { | ||
lines.widest_line = max_line_width; | ||
let remove_counter = 1; | ||
let truncated_string = string; | ||
do { | ||
truncated_string = string.substring(0, string.length - remove_counter) + "…"; | ||
string_width = ctx.measureText(truncated_string).width; | ||
} while ((string_width > width) && (++remove_counter < string.length)); | ||
if (truncated_string.length < 1) truncated_string = string.substring(0, 1) + "…"; | ||
return truncated_string; | ||
}; | ||
ctx.font = font_styles; | ||
let lines = []; | ||
Object.defineProperty(lines, "widest_line", { | ||
value: 0, | ||
enumerable: true, | ||
writable: true | ||
}); | ||
Object.defineProperty(lines, "has_truncated", { | ||
value: false, | ||
enumerable: true, | ||
writable: true | ||
}); | ||
if (typeof text === "undefined" || (Number.isInteger(max_lines) && max_lines <= 0)) return lines; | ||
if (text.length === 0) { | ||
lines.push(""); | ||
return lines; | ||
} | ||
if (max_width <= 0) { | ||
if (text.length > 0) { | ||
lines.push(text.slice(0, 1) + "…"); | ||
lines.widest_line = ctx.measureText(lines[0]).width; | ||
} | ||
return lines; | ||
} | ||
ctx.font = font_styles; | ||
if (!text || text.length === 0 || max_lines === 0 || max_width <= 0) return lines; | ||
const all_words = text.split(/\s+/g); | ||
// if everything fits in one line, leave as a single string | ||
var text_width = ctx.measureText(text).width; | ||
if (text_width <= max_width) { | ||
// Check if whole label fits ok - no work needed - return it | ||
if (!isOverflow(text)) { | ||
lines.push(text); | ||
max_line_width = text_width; | ||
lines.widest_line = ctx.measureText(text).width; | ||
lines.has_truncated = false; | ||
return lines; | ||
} | ||
// Otherwise… | ||
else { | ||
// Create array of words | ||
var words = text.trim().split(/\s+/g); | ||
// Loop through the words, adding them until they won't fit | ||
var current_line = ""; | ||
for (var i = 0; i < words.length; i++) { | ||
var word = words[i]; | ||
var string = current_line + (current_line ? " " : "") + word; | ||
var string_width = ctx.measureText(string).width; | ||
// If the string including the next word fits, update the | ||
// current_line and the max_width | ||
if (string_width <= max_width) { | ||
current_line = string; | ||
max_line_width = Math.max(max_line_width, string_width); | ||
} | ||
// If we reach this point, the text has overflowed, | ||
// so should always result in a truncation or a new line | ||
else { | ||
var last_line = lines.length + 1 == text_max_lines; | ||
if (!last_line && current_line) { | ||
lines.push(current_line); | ||
var word_width = ctx.measureText(word).width; | ||
if (word_width <= max_width) { | ||
current_line = word; | ||
max_line_width = Math.max(max_line_width, word_width); | ||
continue; | ||
} | ||
// Check if the first word doesn't even fit in the space - return truncated first word | ||
let first_word = new String(all_words[0]); | ||
if (first_word && isOverflow(first_word)) { | ||
if (allow_single_word_truncation) { | ||
first_word = truncateStringToWidth(first_word, max_width); | ||
} | ||
else if (all_words.length > 1) { | ||
first_word += "…"; | ||
} | ||
lines.push(first_word); | ||
lines.widest_line = ctx.measureText(first_word).width; | ||
lines.has_truncated = true; | ||
return lines; | ||
} | ||
// Fit words in line by line, truncating if necessary - return | ||
let currentLine = ""; | ||
let has_truncated = false; | ||
all_words.forEach(word => { | ||
if (has_truncated) return; | ||
if (isOverflow(currentLine + (currentLine ? " " : "") + word)) { | ||
if (currentLine) { | ||
const currentLineWidth = ctx.measureText(currentLine).width; | ||
if (currentLineWidth > max_width) { | ||
currentLine = truncateStringToWidth(currentLine, max_width); | ||
has_truncated = true; | ||
} | ||
// Truncate | ||
var remove_counter = 1; | ||
var truncated_string = current_line; | ||
do { | ||
truncated_string = current_line.substring(0, string.length - remove_counter) + "…"; | ||
string_width = ctx.measureText(truncated_string).width; | ||
} | ||
while ((string_width > max_width) && (++remove_counter < string.length)); | ||
max_line_width = Math.max(max_line_width, string_width); | ||
current_line = truncated_string; | ||
break; | ||
lines.push(currentLine); | ||
currentLine = ""; | ||
} | ||
currentLine = word; | ||
} | ||
lines.push(current_line); | ||
else { | ||
currentLine += (currentLine ? " " : "") + word; | ||
} | ||
}); | ||
if (currentLine && !has_truncated) { | ||
lines.push(truncateStringToWidth(currentLine, max_width)); | ||
} | ||
lines.widest_line = max_line_width; | ||
if (lines.length > max_lines) { | ||
// can't have more lines than max_lines, so slice and add ellipses. | ||
const to_truncate = lines.length > max_lines; | ||
if (to_truncate && Number.isInteger(max_lines)) { | ||
lines.splice(max_lines, lines.length - max_lines); | ||
let last_line = lines[lines.length - 1]; | ||
// check if adding an ellipsis will exceed max_width | ||
if (isOverflow(last_line + "…")) { | ||
// truncate further before adding ellipsis | ||
last_line = truncateStringToWidth(last_line, max_width - ctx.measureText("…").width); | ||
} | ||
// add ellipsis if not already present | ||
if (last_line.slice(-1) !== "…") { | ||
last_line += "…"; | ||
} | ||
lines[lines.length - 1] = last_line; | ||
has_truncated = true; | ||
} | ||
} | ||
// find the widest entry in lines and measure it | ||
lines.widest_line = Math.max(...lines.map(line => ctx.measureText(line).width)); | ||
lines.has_truncated = has_truncated; | ||
return lines; | ||
} | ||
function getUniqueValuesFromBinding(data, binding, index) { | ||
@@ -163,3 +223,2 @@ if (data.column_names[binding] === undefined) return []; | ||
export { remToPx, getTextDimensions, getTextWidth, getTextHeight, getTextDirection, isImage, isPale, isUrl, hexToColor, hexToRgba, wrapStringToLines, getUniqueValuesFromBinding, createScreenshotSVG }; |
@@ -10,3 +10,3 @@ import { expect } from "chai"; | ||
it("correctly truncates multiple lines", () => { | ||
const lines = wrapStringToLines("This is a particularly long Y axis tick that should wrap onto multiple lines", "Arial 12px", 3, 100); | ||
const lines = wrapStringToLines("This is a particularly long Y axis tick that should wrap onto multiple lines", "normal 11px Arial", 3, 100); | ||
@@ -18,6 +18,35 @@ expect(lines).deep.eq([ | ||
]); | ||
const lines_2 = wrapStringToLines("Trent Alexander-Arnold", "normal 12px Arial", 3, 66); | ||
expect(lines_2).deep.eq([ | ||
"Trent", | ||
"Alexande…" | ||
]); | ||
const lines_3 = wrapStringToLines("Alexander-Arnold Trent", "normal 12px Arial", 3, 66); | ||
expect(lines_3).deep.eq([ | ||
"Alexande…" | ||
]); | ||
const lines_4 = wrapStringToLines("Trent Alexander-Arnold", "normal 12px Arial", 3, 28); | ||
expect(lines_4).deep.eq([ | ||
"Trent", | ||
"Al…" | ||
]); | ||
const lines_5 = wrapStringToLines("Virgil van Dijk", "normal 10px Arial", 3, 51); | ||
expect(lines_5).deep.eq([ | ||
"Virgil van", | ||
"Dijk" | ||
]); | ||
const lines_6 = wrapStringToLines("Virgil van Dijk", "normal 14.399999999999999px Arial", 3, 53.6); | ||
expect(lines_6).deep.eq([ | ||
"Virgil", | ||
"van Dijk" | ||
]); | ||
}); | ||
it("returns no lines when max_lines is less than 1", () => { | ||
const lines = wrapStringToLines("This is a string", "Arial 12px", 0, 100); | ||
const lines = wrapStringToLines("This is a string", "normal 14px Arial", 0, 100); | ||
@@ -28,6 +57,91 @@ expect(lines).deep.eq([]); | ||
it("does not return no lines when max_lines is null", () => { | ||
const lines =wrapStringToLines("This is a string", "Arial 12px", null, 100); | ||
const lines = wrapStringToLines("This is a string", "normal 14px Arial", null, 100); | ||
expect(lines).deep.eq(["This is a string"]); | ||
}); | ||
it("handles number input", () => { | ||
const number_input = 43; | ||
const number_output = wrapStringToLines(number_input, "normal 14px Arial", 2, 100); | ||
expect(number_output).deep.eq(["43"]); | ||
}); | ||
it("handles empty string input", () => { | ||
const empty_string_input = ""; | ||
const empty_string_output = wrapStringToLines(empty_string_input, "normal 14px Arial", 2, 100); | ||
expect(empty_string_output).deep.eq([""]); | ||
}); | ||
it("handles whitespace input", () => { | ||
const whitespace_input = " "; | ||
const whitespace_output = wrapStringToLines(whitespace_input, "normal 14px Arial", 2, 100); | ||
expect(whitespace_output).deep.eq([" "]); | ||
}); | ||
it("handles negative max_width", () => { | ||
const negative_max_width_input = -50; | ||
const negative_max_width_output = wrapStringToLines("Test string", "normal 14px Arial", 2, negative_max_width_input); | ||
expect(negative_max_width_output).deep.eq(["T…"]); | ||
}); | ||
it("handles zero max_width", () => { | ||
const zero_max_width_input = "Test string"; | ||
const zero_max_width_output = wrapStringToLines(zero_max_width_input, "normal 14px Arial", 2, 0); | ||
expect(zero_max_width_output).deep.eq(["T…"]); | ||
}); | ||
it("handles a single long word", () => { | ||
const single_long_word_input = "Supercalifragilisticexpialidocious"; | ||
const single_long_word_output = wrapStringToLines(single_long_word_input, "normal 14px Arial", 2, 50); | ||
expect(single_long_word_output).deep.eq(["Supe…"]); | ||
}); | ||
it("handles different font styles", () => { | ||
const different_font_styles_input = "normal Times New Roman 12px"; | ||
const different_font_styles_output = wrapStringToLines("Test string", different_font_styles_input, 2, 100); | ||
expect(different_font_styles_output).deep.eq(["Test string"]); | ||
}); | ||
it("handles extremely small font size", () => { | ||
const small_font_size_input = "normal 1px Arial"; | ||
const small_font_size_output = wrapStringToLines("Test string", small_font_size_input, 2, 100); | ||
expect(small_font_size_output).deep.eq(["Test string"]); | ||
}); | ||
it("handles extremely large font size", () => { | ||
const large_font_size_input = "normal 100px Arial"; | ||
const large_font_size_output = wrapStringToLines("Test string", large_font_size_input, 2, 100); | ||
expect(large_font_size_output).deep.eq(["T…"]); | ||
}); | ||
it("handles non-boolean allow_single_word_truncation", () => { | ||
const non_boolean_truncation_input = "Test string"; | ||
const non_boolean_truncation_output = wrapStringToLines(non_boolean_truncation_input, "normal 14px Arial", 2, 50, "true"); | ||
expect(non_boolean_truncation_output).length.above(0); | ||
}); | ||
it("handles very long text input", () => { | ||
const long_text_input = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.".repeat(10); | ||
const very_long_input_output = wrapStringToLines(long_text_input, "normal 14px Arial", 5, 100); | ||
expect(very_long_input_output).deep.eq([ | ||
"Lorem ipsum", | ||
"dolor sit amet,", | ||
"consectetur", | ||
"adipiscing elit,", | ||
"sed do…", | ||
]); | ||
}); | ||
it("handles special characters", () => { | ||
const special_characters_input = "!@#$%^&*()"; | ||
const special_characters_output = wrapStringToLines(special_characters_input, "normal 14px Arial", 2, 100); | ||
expect(special_characters_output).deep.eq([ "!@#$%^&*()" ]); | ||
}); | ||
it("handles numerical inputs", () => { | ||
const numerical_inputs = "1234567890"; | ||
const numerical_inputs_output = wrapStringToLines(numerical_inputs, "normal 14px Arial", 2, 50); | ||
expect(numerical_inputs_output).deep.eq([ "1234…" ]); | ||
}); | ||
}); |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
58913
6.62%1343
5.42%13
30%2
Infinity%+ Added
- Removed
Updated