diff --git a/README.md b/README.md index 3532ca54c9..df2c64949a 100644 --- a/README.md +++ b/README.md @@ -1109,6 +1109,8 @@ The following text-specific constant options are also supported: * **textAnchor** - the [text anchor](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor) for horizontal position; start, end, or middle * **lineAnchor** - the line anchor for vertical position; top, bottom, or middle * **lineHeight** - the line height in ems; defaults to 1 +* **lineWidth** - the line width in ems, for wrapping; defaults to Infinity +* **monospace** - if true, changes the default fontFamily and metrics to monospace * **fontFamily** - the font name; defaults to [system-ui](https://drafts.csswg.org/css-fonts-4/#valdef-font-family-system-ui) * **fontSize** - the font size in pixels; defaults to 10 * **fontStyle** - the [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style); defaults to normal @@ -1117,6 +1119,8 @@ The following text-specific constant options are also supported: * **frameAnchor** - the frame anchor; top-left, top, top-right, right, bottom-right, bottom, bottom-left, left, or middle (default) * **rotate** - the rotation angle in degrees clockwise; defaults to 0 +If a **lineWidth** is specified, input text values will be wrapped as needed to fit while preserving existing newlines. The line wrapping implementation is rudimentary; for non-ASCII, non-U.S. English text, or for when a different font is used, you may get better results by hard-wrapping the text yourself (by supplying newlines in the input). If the **monospace** option is truthy, the default **fontFamily** changes to “ui-monospace, monospace”, and the **lineWidth** option is interpreted as characters (ch) rather than ems. + The **fontSize** and **rotate** options can be specified as either channels or constants. When fontSize or rotate is specified as a number, it is interpreted as a constant; otherwise it is interpreted as a channel. If the **frameAnchor** option is not specified, then **textAnchor** and **lineAnchor** default to middle. Otherwise, **textAnchor** defaults to start if **frameAnchor** is on the left, end if **frameAnchor** is on the right, and otherwise middle. Similarly, **lineAnchor** defaults to top if **frameAnchor** is on the top, bottom if **frameAnchor** is on the bottom, and otherwise middle. diff --git a/src/marks/text.js b/src/marks/text.js index fab696314a..a3cf632663 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -19,7 +19,9 @@ export class Text extends Mark { textAnchor = /right$/i.test(frameAnchor) ? "end" : /left$/i.test(frameAnchor) ? "start" : "middle", lineAnchor = /^top/i.test(frameAnchor) ? "top" : /^bottom/i.test(frameAnchor) ? "bottom" : "middle", lineHeight = 1, - fontFamily, + lineWidth = Infinity, + monospace, + fontFamily = monospace ? "ui-monospace, monospace" : undefined, fontSize, fontStyle, fontVariant, @@ -44,6 +46,8 @@ export class Text extends Mark { this.textAnchor = impliedString(textAnchor, "middle"); this.lineAnchor = keyword(lineAnchor, "lineAnchor", ["top", "middle", "bottom"]); this.lineHeight = +lineHeight; + this.lineWidth = +lineWidth; + this.monospace = !!monospace; this.fontFamily = string(fontFamily); this.fontSize = cfontSize; this.fontStyle = string(fontStyle); @@ -81,11 +85,15 @@ export class Text extends Mark { } } -function applyMultilineText(selection, {lineAnchor, lineHeight}, T) { +function applyMultilineText(selection, {monospace, lineAnchor, lineHeight, lineWidth}, T) { if (!T) return; const format = isTemporal(T) ? isoFormat : isNumeric(T) ? formatNumber() : string; + const linesof = isFinite(lineWidth) ? (monospace + ? t => lineWrap(t, lineWidth, monospaceWidth) + : t => lineWrap(t, lineWidth * 100, defaultWidth)) + : t => t.split(/\r\n?|\n/g); selection.each(function(i) { - const lines = format(T[i]).split(/\r\n?|\n/g); + const lines = linesof(format(T[i])); const n = lines.length; const y = lineAnchor === "top" ? 0.71 : lineAnchor === "bottom" ? 1 - n : (164 - n * 100) / 200; if (n > 1) { @@ -162,3 +170,109 @@ function maybeFontSizeChannel(fontSize) { ? [undefined, fontSize] : [fontSize, undefined]; } + +// This is a greedy algorithm for line wrapping. It would be better to use the +// Knuth–Plass line breaking algorithm (but that would be much more complex). +// https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap +function lineWrap(input, maxWidth, widthof = (_, i, j) => j - i) { + const lines = []; + let lineStart, lineEnd = 0; + for (const [wordStart, wordEnd, required] of lineBreaks(input)) { + // Record the start of a line. This isn’t the same as the previous line’s + // end because we often skip spaces between lines. + if (lineStart === undefined) lineStart = wordStart; + + // If the current line is not empty, and if adding the current word would + // make the line longer than the allowed width, then break the line at the + // previous word end. + if (lineEnd > lineStart && widthof(input, lineStart, wordEnd) > maxWidth) { + lines.push(input.slice(lineStart, lineEnd)); + lineStart = wordStart; + } + + // If this is a required break (a newline), emit the line and reset. + if (required) { + lines.push(input.slice(lineStart, wordEnd)); + lineStart = undefined; + continue; + } + + // Extend the current line to include the new word. + lineEnd = wordEnd; + } + return lines; +} + +// This is a rudimentary (and U.S.-centric) algorithm for finding opportunities +// to break lines between words. A better and far more comprehensive approach +// would be to use the official Unicode Line Breaking Algorithm. +// https://unicode.org/reports/tr14/ +function* lineBreaks(input) { + let i = 0, j = 0; + const n = input.length; + while (j < n) { + let k = 1; + switch (input[j]) { + case "-": // hyphen + ++j; + yield [i, j, false]; + i = j; + break; + case " ": + yield [i, j, false]; + while (input[++j] === " "); // skip multiple spaces + i = j; + break; + case "\r": if (input[j + 1] === "\n") ++k; // falls through + case "\n": + yield [i, j, true]; + j += k; + i = j; + break; + default: + ++j; + break; + } + } + yield [i, j, true]; +} + +// Computed as round(measureText(text).width * 10) at 10px system-ui. For +// characters that are not represented in this map, we’d ideally want to use a +// weighted average of what we expect to see. But since we don’t really know +// what that is, using “e” seems reasonable. +const defaultWidthMap = { + a: 56, b: 63, c: 57, d: 63, e: 58, f: 37, g: 62, h: 60, i: 26, j: 26, k: 55, l: 26, m: 88, n: 60, o: 60, p: 62, q: 62, r: 39, s: 54, t: 38, u: 60, v: 55, w: 79, x: 54, y: 55, z: 55, + A: 69, B: 67, C: 73, D: 74, E: 61, F: 58, G: 76, H: 75, I: 28, J: 55, K: 67, L: 58, M: 89, N: 75, O: 78, P: 65, Q: 78, R: 67, S: 65, T: 65, U: 75, V: 69, W: 98, X: 69, Y: 67, Z: 67, + 0: 64, 1: 48, 2: 62, 3: 64, 4: 66, 5: 63, 6: 65, 7: 58, 8: 65, 9: 65, + " ": 29, "!": 32, '"': 49, "'": 31, "(": 39, ")": 39, ",": 31, "-": 48, ".": 31, "/": 32, ":": 31, ";": 31, "?": 52, "‘": 31, "’": 31, "“": 47, "”": 47 +}; + +// This is a rudimentary (and U.S.-centric) algorithm for measuring the width of +// a string based on a technique of Gregor Aisch; it assumes that individual +// characters are laid out independently and does not implement the Unicode +// grapheme cluster breaking algorithm. It does understand code points, though, +// and so treats things like emoji as having the width of a lowercase e (and +// should be equivalent to using for-of to iterate over code points, while also +// being fast). TODO Optimize this by noting that we often re-measure characters +// that were previously measured? +// http://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries +// https://exploringjs.com/impatient-js/ch_strings.html#atoms-of-text +function defaultWidth(text, start, end) { + let sum = 0; + for (let i = start; i < end; ++i) { + sum += defaultWidthMap[text[i]] || defaultWidthMap.e; + const first = text.charCodeAt(i); + if (first >= 0xd800 && first <= 0xdbff) { // high surrogate + const second = text.charCodeAt(i + 1); + if (second >= 0xdc00 && second <= 0xdfff) { // low surrogate + ++i; // surrogate pair + } + } + } + return sum; +} + +function monospaceWidth(text, start, end) { + return end - start; +} diff --git a/test/output/mobyDick.svg b/test/output/mobyDick.svg new file mode 100644 index 0000000000..344228ad6e --- /dev/null +++ b/test/output/mobyDick.svg @@ -0,0 +1,174 @@ + + + + Call me Ishmael. Some years ago—never mind how long precisely—having little or + no money in my purse, and nothing particular to interest me on shore, I thought I + would sail about a little and see the watery part of the world. It is a way I have of + driving off the spleen and regulating the circulation. Whenever I find myself + growing grim about the mouth; whenever it is a damp, drizzly November in my + soul; whenever I find myself involuntarily pausing before coffin warehouses, and + bringing up the rear of every funeral I meet; and especially whenever my hypos get + such an upper hand of me, that it requires a strong moral principle to prevent me + from deliberately stepping into the street, and methodically knocking people’s + hats off—then, I account it high time to get to sea as soon as I can. This is my + substitute for pistol and ball. With a philosophical flourish Cato throws himself + upon his sword; I quietly take to the ship. There is nothing surprising in this. If they + but knew it, almost all men in their degree, some time or other, cherish very nearly + the same feelings towards the ocean with me. + There now is your insular city of the Manhattoes, belted round by wharves as + Indian isles by coral reefs—commerce surrounds it with her surf. Right and left, the + streets take you waterward. Its extreme downtown is the battery, where that noble + mole is washed by waves, and cooled by breezes, which a few hours previous + were out of sight of land. Look at the crowds of water-gazers there. + Circumambulate the city of a dreamy Sabbath afternoon. Go from Corlears Hook to + Coenties Slip, and from thence, by Whitehall, northward. What do you + see?—Posted like silent sentinels all around the town, stand thousands upon + thousands of mortal men fixed in ocean reveries. Some leaning against the spiles; + some seated upon the pier-heads; some looking over the bulwarks of ships from + China; some high aloft in the rigging, as if striving to get a still better seaward + peep. But these are all landsmen; of week days pent up in lath and plaster—tied to + counters, nailed to benches, clinched to desks. How then is this? Are the green + fields gone? What do they here? + But look! here come more crowds, pacing straight for the water, and seemingly + bound for a dive. Strange! Nothing will content them but the extremest limit of the + land; loitering under the shady lee of yonder warehouses will not suffice. No. They + must get just as nigh the water as they possibly can without falling in. And there + they stand—miles of them—leagues. Inlanders all, they come from lanes and alleys, + streets and avenues—north, east, south, and west. Yet here they all unite. Tell me, + does the magnetic virtue of the needles of the compasses of all those ships attract + them thither? + Once more. Say you are in the country; in some high land of lakes. Take almost + any path you please, and ten to one it carries you down in a dale, and leaves you + there by a pool in the stream. There is magic in it. Let the most absent-minded of + men be plunged in his deepest reveries—stand that man on his legs, set his feet a- + going, and he will infallibly lead you to water, if water there be in all that region. + Should you ever be athirst in the great American desert, try this experiment, if + your caravan happen to be supplied with a metaphysical professor. Yes, as every + one knows, meditation and water are wedded for ever. + But here is an artist. He desires to paint you the dreamiest, shadiest, quietest, + most enchanting bit of romantic landscape in all the valley of the Saco. What is the + chief element he employs? There stand his trees, each with a hollow trunk, as if a + hermit and a crucifix were within; and here sleeps his meadow, and there sleep his + cattle; and up from yonder cottage goes a sleepy smoke. Deep into distant + woodlands winds a mazy way, reaching to overlapping spurs of mountains bathed + in their hill-side blue. But though the picture lies thus tranced, and though this + pine-tree shakes down its sighs like leaves upon this shepherd’s head, yet all were + vain, unless the shepherd’s eye were fixed upon the magic stream before him. Go + visit the Prairies in June, when for scores on scores of miles you wade knee-deep + among Tiger-lilies—what is the one charm wanting?—Water—there is not a drop of + water there! Were Niagara but a cataract of sand, would you travel your thousand + miles to see it? Why did the poor poet of Tennessee, upon suddenly receiving two + handfuls of silver, deliberate whether to buy him a coat, which he sadly needed, or + invest his money in a pedestrian trip to Rockaway Beach? Why is almost every + robust healthy boy with a robust healthy soul in him, at some time or other crazy + to go to sea? Why upon your first voyage as a passenger, did you yourself feel + such a mystical vibration, when first told that you and your ship were now out of + sight of land? Why did the old Persians hold the sea holy? Why did the Greeks give + it a separate deity, and own brother of Jove? Surely all this is not without meaning. + And still deeper the meaning of that story of Narcissus, who because he could not + grasp the tormenting, mild image he saw in the fountain, plunged into it and was + drowned. But that same image, we ourselves see in all rivers and oceans. It is the + image of the ungraspable phantom of life; and this is the key to it all. + Now, when I say that I am in the habit of going to sea whenever I begin to grow + hazy about the eyes, and begin to be over conscious of my lungs, I do not mean to + have it inferred that I ever go to sea as a passenger. For to go as a passenger you + must needs have a purse, and a purse is but a rag unless you have something in it. + Besides, passengers get sea-sick—grow quarrelsome—don’t sleep of nights—do not + enjoy themselves much, as a general thing;—no, I never go as a passenger; nor, + though I am something of a salt, do I ever go to sea as a Commodore, or a Captain, + or a Cook. I abandon the glory and distinction of such offices to those who like + them. For my part, I abominate all honorable respectable toils, trials, and + tribulations of every kind whatsoever. It is quite as much as I can do to take care + of myself, without taking care of ships, barques, brigs, schooners, and what not. + And as for going as cook,—though I confess there is considerable glory in that, a + cook being a sort of officer on ship-board—yet, somehow, I never fancied broiling + fowls;—though once broiled, judiciously buttered, and judgmatically salted and + peppered, there is no one who will speak more respectfully, not to say + reverentially, of a broiled fowl than I will. It is out of the idolatrous dotings of the + old Egyptians upon broiled ibis and roasted river horse, that you see the mummies + of those creatures in their huge bake-houses the pyramids. + No, when I go to sea, I go as a simple sailor, right before the mast, plumb down + into the forecastle, aloft there to the royal mast-head. True, they rather order me + about some, and make me jump from spar to spar, like a grasshopper in a May + meadow. And at first, this sort of thing is unpleasant enough. It touches one’s + sense of honor, particularly if you come of an old established family in the land, + the Van Rensselaers, or Randolphs, or Hardicanutes. And more than all, if just + previous to putting your hand into the tar-pot, you have been lording it as a + country schoolmaster, making the tallest boys stand in awe of you. The transition + is a keen one, I assure you, from a schoolmaster to a sailor, and requires a strong + decoction of Seneca and the Stoics to enable you to grin and bear it. But even this + wears off in time. + What of it, if some old hunks of a sea-captain orders me to get a broom and sweep + down the decks? What does that indignity amount to, weighed, I mean, in the + scales of the New Testament? Do you think the archangel Gabriel thinks anything + the less of me, because I promptly and respectfully obey that old hunks in that + particular instance? Who ain’t a slave? Tell me that. Well, then, however the old + sea-captains may order me about—however they may thump and punch me about, I + have the satisfaction of knowing that it is all right; that everybody else is one way + or other served in much the same way—either in a physical or metaphysical point of + view, that is; and so the universal thump is passed round, and all hands should rub + each other’s shoulder-blades, and be content. + Again, I always go to sea as a sailor, because they make a point of paying me for + my trouble, whereas they never pay passengers a single penny that I ever heard + of. On the contrary, passengers themselves must pay. And there is all the + difference in the world between paying and being paid. The act of paying is + perhaps the most uncomfortable infliction that the two orchard thieves entailed + upon us. But being paid,—what will compare with it? The urbane activity with which + a man receives money is really marvellous, considering that we so earnestly + believe money to be the root of all earthly ills, and that on no account can a + monied man enter heaven. Ah! how cheerfully we consign ourselves to perdition! + Finally, I always go to sea as a sailor, because of the wholesome exercise and pure + air of the fore-castle deck. For as in this world, head winds are far more prevalent + than winds from astern (that is, if you never violate the Pythagorean maxim), so for + the most part the Commodore on the quarter-deck gets his atmosphere at second + hand from the sailors on the forecastle. He thinks he breathes it first; but not so. In + much the same way do the commonalty lead their leaders in many other things, at + the same time that the leaders little suspect it. But wherefore it was that after + having repeatedly smelt the sea as a merchant sailor, I should now take it into my + head to go on a whaling voyage; this the invisible police officer of the Fates, who + has the constant surveillance of me, and secretly dogs me, and influences me in + some unaccountable way—he can better answer than any one else. And, doubtless, + my going on this whaling voyage, formed part of the grand programme of + Providence that was drawn up a long time ago. It came in as a sort of brief + interlude and solo between more extensive performances. I take it that this part of + the bill must have run something like this: + “Grand Contested Election for the Presidency of the United States. “WHALING + VOYAGE BY ONE ISHMAEL. “BLOODY BATTLE IN AFFGHANISTAN.” + Though I cannot tell why it was exactly that those stage managers, the Fates, put + me down for this shabby part of a whaling voyage, when others were set down for + magnificent parts in high tragedies, and short and easy parts in genteel comedies, + and jolly parts in farces—though I cannot tell why this was exactly; yet, now that I + recall all the circumstances, I think I can see a little into the springs and motives + which being cunningly presented to me under various disguises, induced me to set + about performing the part I did, besides cajoling me into the delusion that it was a + choice resulting from my own unbiased freewill and discriminating judgment. + Chief among these motives was the overwhelming idea of the great whale himself. + Such a portentous and mysterious monster roused all my curiosity. Then the wild + and distant seas where he rolled his island bulk; the undeliverable, nameless perils + of the whale; these, with all the attending marvels of a thousand Patagonian sights + and sounds, helped to sway me to my wish. With other men, perhaps, such things + would not have been inducements; but as for me, I am tormented with an + everlasting itch for things remote. I love to sail forbidden seas, and land on + barbarous coasts. Not ignoring what is good, I am quick to perceive a horror, and + could still be social with it—would they let me—since it is but well to be on friendly + terms with all the inmates of the place one lodges in. + By reason of these things, then, the whaling voyage was welcome; the great flood- + gates of the wonder-world swung open, and in the wild conceits that swayed me + to my purpose, two and two there floated into my inmost soul, endless + processions of the whale, and, mid most of them all, one grand hooded phantom, + like a snow hill in the air. + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index aa5be8d0d3..d0c28cda85 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -75,6 +75,7 @@ export {default as metroUnemploymentMoving} from "./metro-unemployment-moving.js export {default as metroUnemploymentNormalize} from "./metro-unemployment-normalize.js"; export {default as metroUnemploymentRidgeline} from "./metro-unemployment-ridgeline.js"; export {default as metroUnemploymentStroke} from "./metro-unemployment-stroke.js"; +export {default as mobyDick} from "./moby-dick.js"; export {default as mobyDickFaceted} from "./moby-dick-faceted.js"; export {default as mobyDickLetterFrequency} from "./moby-dick-letter-frequency.js"; export {default as mobyDickLetterPairs} from "./moby-dick-letter-pairs.js"; diff --git a/test/plots/moby-dick.js b/test/plots/moby-dick.js new file mode 100644 index 0000000000..fc7fa22bcd --- /dev/null +++ b/test/plots/moby-dick.js @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const mobydick = await d3.text("data/moby-dick-chapter-1.txt"); + return Plot.plot({ + height: 1200, + marks: [ + Plot.text([mobydick], {fontSize: 14, lineWidth: 40, lineHeight: 1.2, frameAnchor: "top-left"}) + ] + }); +}