diff --git a/src/transforms/tree.js b/src/transforms/tree.js
index 44d42cf474..067fc03229 100644
--- a/src/transforms/tree.js
+++ b/src/transforms/tree.js
@@ -167,18 +167,66 @@ function nodeData(field) {
}
function normalizer(delimiter = "/") {
- return `${delimiter}` === "/"
- ? (P) => P // paths are already slash-separated
- : (P) => P.map(replaceAll(delimiter, "/")); // TODO string.replaceAll when supported
+ delimiter = `${delimiter}`;
+ if (delimiter === "/") return (P) => P; // paths are already slash-separated
+ if (delimiter.length !== 1) throw new Error("delimiter must be exactly one character");
+ const delimiterCode = delimiter.charCodeAt(0);
+ return (P) => P.map((p) => slashDelimiter(p, delimiterCode));
}
-function replaceAll(search, replace) {
- search = new RegExp(regexEscape(search), "g");
- return (value) => (value == null ? null : `${value}`.replace(search, replace));
+const CODE_BACKSLASH = 92;
+const CODE_SLASH = 47;
+
+function slashDelimiter(input, delimiterCode) {
+ if (delimiterCode === CODE_BACKSLASH) throw new Error("delimiter cannot be backslash");
+ let afterBackslash = false;
+ for (let i = 0, n = input.length; i < n; ++i) {
+ switch (input.charCodeAt(i)) {
+ case CODE_BACKSLASH:
+ if (!afterBackslash) {
+ afterBackslash = true;
+ continue;
+ }
+ break;
+ case delimiterCode:
+ if (afterBackslash) {
+ (input = input.slice(0, i - 1) + input.slice(i)), --i, --n; // remove backslash
+ } else {
+ input = input.slice(0, i) + "/" + input.slice(i + 1); // replace delimiter with slash
+ }
+ break;
+ case CODE_SLASH:
+ if (afterBackslash) {
+ (input = input.slice(0, i) + "\\\\" + input.slice(i)), (i += 2), (n += 2); // add two backslashes
+ } else {
+ (input = input.slice(0, i) + "\\" + input.slice(i)), ++i, ++n; // add backslash
+ }
+ break;
+ }
+ afterBackslash = false;
+ }
+ return input;
}
-function regexEscape(string) {
- return `${string}`.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
+function slashUnescape(input) {
+ let afterBackslash = false;
+ for (let i = 0, n = input.length; i < n; ++i) {
+ switch (input.charCodeAt(i)) {
+ case CODE_BACKSLASH:
+ if (!afterBackslash) {
+ afterBackslash = true;
+ continue;
+ }
+ // eslint-disable-next-line no-fallthrough
+ case CODE_SLASH:
+ if (afterBackslash) {
+ (input = input.slice(0, i - 1) + input.slice(i)), --i, --n; // remove backslash
+ }
+ break;
+ }
+ afterBackslash = false;
+ }
+ return input;
}
function isNodeValue(option) {
@@ -272,7 +320,7 @@ function parentValue(evaluate) {
function nameof(path) {
let i = path.length;
while (--i > 0) if (slash(path, i)) break;
- return path.slice(i + 1);
+ return slashUnescape(path.slice(i + 1));
}
// Slashes can be escaped; to determine whether a slash is a path delimiter, we
diff --git a/test/output/treeDelimiter.svg b/test/output/treeDelimiter.svg
new file mode 100644
index 0000000000..f806f3cc9d
--- /dev/null
+++ b/test/output/treeDelimiter.svg
@@ -0,0 +1,80 @@
+
\ No newline at end of file
diff --git a/test/output/treeDelimiter2.svg b/test/output/treeDelimiter2.svg
new file mode 100644
index 0000000000..f806f3cc9d
--- /dev/null
+++ b/test/output/treeDelimiter2.svg
@@ -0,0 +1,80 @@
+
\ No newline at end of file
diff --git a/test/plots/index.ts b/test/plots/index.ts
index 35ded36808..9998e92373 100644
--- a/test/plots/index.ts
+++ b/test/plots/index.ts
@@ -298,6 +298,7 @@ export * from "./title.js";
export * from "./traffic-horizon.js";
export * from "./travelers-covid-drop.js";
export * from "./travelers-year-over-year.js";
+export * from "./tree-delimiter.js";
export * from "./uniform-random-difference.js";
export * from "./untyped-date-bin.js";
export * from "./us-congress-age-color-explicit.js";
diff --git a/test/plots/tree-delimiter.ts b/test/plots/tree-delimiter.ts
new file mode 100644
index 0000000000..f9492c2005
--- /dev/null
+++ b/test/plots/tree-delimiter.ts
@@ -0,0 +1,50 @@
+import * as Plot from "@observablehq/plot";
+
+export async function treeDelimiter() {
+ return Plot.plot({
+ axis: null,
+ height: 150,
+ margin: 10,
+ marginLeft: 40,
+ marginRight: 190,
+ marks: [
+ Plot.tree(
+ [
+ "foo;a;//example", // foo → a → //example
+ "foo;a;//example/1", // foo → a → //example/1
+ "foo;b;//example/2", // foo → b → //example/2
+ "foo;c\\;c;//example2", // foo → c;c → //example2
+ "foo;d\\\\;d;//example2", // foo → d\ → d → //example3
+ "foo;d\\\\;\\d;//example2", // foo → d\ → \d → //example3
+ "foo;e\\\\\\;e;//example2", // foo → e\;e → //example3
+ "foo;f/f;//example4", // foo → f/f → //example4
+ "foo;g\\/g;//example3" // foo → g\/g → //example3
+ ],
+ {delimiter: ";"}
+ )
+ ]
+ });
+}
+
+export async function treeDelimiter2() {
+ return Plot.plot({
+ axis: null,
+ height: 150,
+ margin: 10,
+ marginLeft: 40,
+ marginRight: 190,
+ marks: [
+ Plot.tree([
+ "foo/a/\\/\\/example", // foo → a → //example
+ "foo/a/\\/\\/example\\/1", // foo → a → //example/1
+ "foo/b/\\/\\/example\\/2", // foo → b → //example/2
+ "foo/c;c/\\/\\/example2", // foo → c;c → //example2
+ "foo/d\\\\/d/\\/\\/example2", // foo → d\ → d → //example3
+ "foo/d\\\\/\\d/\\/\\/example2", // foo → d\ → \d → //example3
+ "foo/e\\\\;e/\\/\\/example2", // foo → e\;e → //example3
+ "foo/f\\/f/\\/\\/example4", // foo → f/f → //example4
+ "foo/g\\\\\\/g/\\/\\/example3" // foo → g\/g → //example3
+ ])
+ ]
+ });
+}