diff --git a/package.json b/package.json index cbdb4ab4de..f73dc8a1b8 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "sideEffects": false, "devDependencies": { + "@rollup/plugin-commonjs": "^21.0.1", "@rollup/plugin-json": "4", "@rollup/plugin-node-resolve": "13", "canvas": "2", @@ -50,6 +51,7 @@ }, "dependencies": { "d3": "^7.3.0", + "interval-tree-1d": "1", "isoformat": "0.2" }, "engines": { diff --git a/rollup.config.js b/rollup.config.js index f2cded1105..ae8f8778b5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,5 +1,6 @@ import fs from "fs"; import {terser} from "rollup-plugin-terser"; +import commonjs from "@rollup/plugin-commonjs"; import json from "@rollup/plugin-json"; import node from "@rollup/plugin-node-resolve"; import * as meta from "./package.json"; @@ -25,6 +26,7 @@ const config = { banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}` }, plugins: [ + commonjs(), json(), node() ] diff --git a/src/index.js b/src/index.js index 7ac7a63778..2eadbeeab7 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Vector, vector} from "./marks/vector.js"; export {valueof} from "./options.js"; +export {dodgeX, dodgeY} from "./layouts/dodge.js"; export {filter, reverse, sort, shuffle} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js new file mode 100644 index 0000000000..5e7371519b --- /dev/null +++ b/src/layouts/dodge.js @@ -0,0 +1,88 @@ +import {max} from "d3"; +import IntervalTree from "interval-tree-1d"; +import {maybeNumberChannel} from "../options.js"; +import {layout} from "./index.js"; + +const anchorXLeft = ({marginLeft}) => [1, marginLeft]; +const anchorXRight = ({width, marginRight}) => [-1, width - marginRight]; +const anchorXMiddle = ({width, marginLeft, marginRight}) => [0, (marginLeft + width - marginRight) / 2]; +const anchorYTop = ({marginTop}) => [1, marginTop]; +const anchorYBottom = ({height, marginBottom}) => [-1, height - marginBottom]; +const anchorYMiddle = ({height, marginTop, marginBottom}) => [0, (marginTop + height - marginBottom) / 2]; + +function maybeAnchor(anchor) { + return typeof anchor === "string" ? {anchor} : anchor; +} + +export function dodgeX(dodgeOptions = {}, options = {}) { + if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options]; + let {anchor = "left", padding = 1} = maybeAnchor(dodgeOptions); + switch (`${anchor}`.toLowerCase()) { + case "left": anchor = anchorXLeft; break; + case "right": anchor = anchorXRight; break; + case "middle": anchor = anchorXMiddle; break; + default: throw new Error(`unknown dodge anchor: ${anchor}`); + } + return dodge("x", "y", anchor, +padding, options); +} + +export function dodgeY(dodgeOptions = {}, options = {}) { + if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options]; + let {anchor = "bottom", padding = 1} = maybeAnchor(dodgeOptions); + switch (`${anchor}`.toLowerCase()) { + case "top": anchor = anchorYTop; break; + case "bottom": anchor = anchorYBottom; break; + case "middle": anchor = anchorYMiddle; break; + default: throw new Error(`unknown dodge anchor: ${anchor}`); + } + return dodge("y", "x", anchor, +padding, options); +} + +function dodge(y, x, anchor, padding, options) { + const [, r] = maybeNumberChannel(options.r, 3); + return layout(options, (I, scales, {[x]: X, r: R}, dimensions) => { + if (X == null) throw new Error(`missing channel: ${x}`); + let [ky, ty] = anchor(dimensions); + const compare = ky ? compareAscending : compareSymmetric; + if (ky) ty += ky * ((R ? max(I, i => R[i]) : r) + padding); else ky = 1; + if (!R) R = new Float64Array(X.length).fill(r); + const Y = new Float64Array(X.length); + const tree = IntervalTree(); + for (const i of I) { + const intervals = []; + const l = X[i] - R[i]; + const r = X[i] + R[i]; + + // For any previously placed circles that may overlap this circle, compute + // the y-positions that place this circle tangent to these other circles. + // https://observablehq.com/@mbostock/circle-offset-along-line + tree.queryInterval(l - padding, r + padding, ([,, j]) => { + const yj = Y[j]; + const dx = X[i] - X[j]; + const dr = R[i] + padding + R[j]; + const dy = Math.sqrt(dr * dr - dx * dx); + intervals.push([yj - dy, yj + dy]); + }); + + // Find the best y-value where this circle can fit. + for (let y of intervals.flat().sort(compare)) { + if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) { + Y[i] = y; + break; + } + } + + // Insert the placed circle into the interval tree. + tree.insert([l, r, i]); + } + return {[y]: Y.map(y => y * ky + ty)}; + }); +} + +function compareSymmetric(a, b) { + return Math.abs(a) - Math.abs(b); +} + +function compareAscending(a, b) { + return (a < 0) - (b < 0) || (a - b); +} diff --git a/src/layouts/index.js b/src/layouts/index.js new file mode 100644 index 0000000000..cee6396209 --- /dev/null +++ b/src/layouts/index.js @@ -0,0 +1,19 @@ +export function layout({layout: layout1, ...options}, layout2) { + if (layout2 == null) throw new Error("invalid layout"); + layout2 = partialLayout(layout2); + if (layout1 != null) layout2 = composeLayout(layout1, layout2); + return {...options, layout: layout2}; +} + +function composeLayout(l1, l2) { + return function(index, scales, values, dimensions) { + values = l1.call(this, index, scales, values, dimensions); + return l2.call(this, index, scales, values, dimensions); + }; +} + +function partialLayout(l) { + return function(index, scales, values, dimensions) { + return {...values, ...l.call(this, index, scales, values, dimensions)}; + }; +} diff --git a/src/plot.js b/src/plot.js index bcbbc92233..857d6fff41 100644 --- a/src/plot.js +++ b/src/plot.js @@ -95,8 +95,9 @@ export function plot(options = {}) { for (const mark of marks) { const channels = markChannels.get(mark) ?? []; - const values = applyScales(channels, scales); + let values = applyScales(channels, scales); const index = filter(markIndex.get(mark), channels, values); + if (mark.layout != null) values = mark.layout(index, scales, values, dimensions); const node = mark.render(index, scales, values, dimensions, axes); if (node != null) svg.appendChild(node); } @@ -132,9 +133,10 @@ function filter(index, channels, values) { export class Mark { constructor(data, channels = [], options = {}, defaults) { - const {facet = "auto", sort, dx, dy} = options; + const {layout, facet = "auto", sort, dx, dy} = options; const names = new Set(); this.data = data; + this.layout = layout; this.sort = isOptions(sort) ? sort : null; this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]); const {transform} = basic(options); @@ -311,9 +313,11 @@ class Facet extends Mark { .each(function(key) { const marksFacetIndex = marksIndexByFacet.get(key); for (let i = 0; i < marks.length; ++i) { - const values = marksValues[i]; + const mark = marks[i]; + let values = marksValues[i]; const index = filter(marksFacetIndex[i], marksChannels[i], values); - const node = marks[i].render(index, scales, values, subdimensions); + if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions); + const node = mark.render(index, scales, values, subdimensions); if (node != null) this.appendChild(node); } })) diff --git a/test/output/penguinDodge.svg b/test/output/penguinDodge.svg new file mode 100644 index 0000000000..5383c14225 --- /dev/null +++ b/test/output/penguinDodge.svg @@ -0,0 +1,383 @@ + + + + + 3,000 + + + 3,500 + + + 4,000 + + + 4,500 + + + 5,000 + + + 5,500 + + + 6,000 + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/penguinFacetDodge.svg b/test/output/penguinFacetDodge.svg new file mode 100644 index 0000000000..c3509dd94c --- /dev/null +++ b/test/output/penguinFacetDodge.svg @@ -0,0 +1,413 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + + 3,000 + + + + 3,500 + + + + 4,000 + + + + 4,500 + + + + 5,000 + + + + 5,500 + + + + 6,000 + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 88ef7e17ed..df7cc1044e 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -87,6 +87,8 @@ export {default as musicRevenue} from "./music-revenue.js"; export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; +export {default as penguinDodge} from "./penguin-dodge.js"; +export {default as penguinFacetDodge} from "./penguin-facet-dodge.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; diff --git a/test/plots/penguin-dodge.js b/test/plots/penguin-dodge.js new file mode 100644 index 0000000000..47fc6ce892 --- /dev/null +++ b/test/plots/penguin-dodge.js @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 200, + marks: [ + Plot.dot(penguins, Plot.dodgeY({x: "body_mass_g"})) + ] + }); +} diff --git a/test/plots/penguin-facet-dodge.js b/test/plots/penguin-facet-dodge.js new file mode 100644 index 0000000000..77ed20a433 --- /dev/null +++ b/test/plots/penguin-facet-dodge.js @@ -0,0 +1,21 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 300, + x: { + grid: true + }, + facet: { + data: penguins, + y: "species", + label: null, + marginLeft: 60 + }, + marks: [ + Plot.dot(penguins, Plot.dodgeY("middle", {x: "body_mass_g"})) + ] + }); +} diff --git a/yarn.lock b/yarn.lock index 6eef348e39..ec7bdc5607 100644 --- a/yarn.lock +++ b/yarn.lock @@ -67,6 +67,19 @@ semver "^7.3.5" tar "^6.1.11" +"@rollup/plugin-commonjs@^21.0.1": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz#1e57c81ae1518e4df0954d681c642e7d94588fee" + integrity sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + commondir "^1.0.1" + estree-walker "^2.0.1" + glob "^7.1.6" + is-reference "^1.2.1" + magic-string "^0.25.7" + resolve "^1.17.0" + "@rollup/plugin-json@4": version "4.1.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" @@ -100,6 +113,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@types/estree@*": + version "0.0.50" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" + integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -242,6 +260,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +binary-search-bounds@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz#125e5bd399882f71e6660d4bf1186384e989fba7" + integrity sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -388,6 +411,11 @@ commander@^2.19.0, commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1021,6 +1049,11 @@ estree-walker@^1.0.1: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== +estree-walker@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -1163,7 +1196,7 @@ glob@7.1.7: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.1.3: +glob@^7.1.3, glob@^7.1.6: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -1296,6 +1329,13 @@ ini@^1.3.4: resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== +interval-tree-1d@1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz#b44f657de7ddae69ea3f98e0a9ad4bb046b07d11" + integrity sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ== + dependencies: + binary-search-bounds "^2.0.0" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -1303,7 +1343,7 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-core-module@^2.8.0: +is-core-module@^2.8.0, is-core-module@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== @@ -1347,6 +1387,13 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-reference@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" + integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== + dependencies: + "@types/estree" "*" + is-unicode-supported@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" @@ -1487,6 +1534,13 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +magic-string@^0.25.7: + version "0.25.7" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" + integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== + dependencies: + sourcemap-codec "^1.4.4" + make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -1811,6 +1865,15 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve@^1.17.0: + version "1.22.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" + integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== + dependencies: + is-core-module "^2.8.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^1.19.0, resolve@^1.20.0: version "1.21.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f" @@ -1966,6 +2029,11 @@ source-map@~0.7.2: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== +sourcemap-codec@^1.4.4: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"