diff --git a/docs/assets/tree-canvas.js b/docs/assets/tree-canvas.js index d376742..df6f2df 100644 --- a/docs/assets/tree-canvas.js +++ b/docs/assets/tree-canvas.js @@ -22,9 +22,27 @@ window.karyonCanvas = (function () { var LABEL_ROOM = 9; + // The rail down the right, and the narrowest canvas that gets one. It is a + // scrollbar that shows what it is scrolling through, so it is read at a + // glance and never zoomed: it always holds the whole tree. + var RAIL_WIDE = 78; + var RAIL_PAD = 5; + var RAIL_LEAST = 420; + // A window of three rows in two million is a thousandth of a pixel tall. The + // mark for it stays this big so there is always something to see and to grab. + var MARK_LEAST = 4; + + // The dial: the whole disc, small, in the corner furthest from the middle of + // the canvas, and the gap it keeps from the edge. + var DIAL_SIDE = 132; + var DIAL_EDGE = 10; + function make(canvas) { var view = null; var camera = null; + // The disc's own camera, and which of the two is being looked through. + var disc = null; + var round = false; var decoder = new TextDecoder(); function nameOf(node) { @@ -60,13 +78,20 @@ window.karyonCanvas = (function () { view.bounds = { lowX: lowX, highX: highX, lowY: lowY, highY: highY }; // Room to mark who is already on the list while a paint walks upward, // and a counter so the marks are told apart by age rather than cleared. + // The root, which every walk ends at and which the pixel test needs the + // depth of before any walk has run. + view.root = 0; + for (var look = 0; look < placed.count; look++) { + if (placed.parent[look] === 0xffffffff) { view.root = look; break; } + } view.seen = new Int32Array(placed.count); view.visit = 0; - view.shown = new Uint32Array(1 << 14); + view.shown = { child: new Uint32Array(1 << 14), up: new Uint32Array(1 << 14) }; home(); } function home() { + if (round) { discHome(); return; } var b = view.bounds; // A margin on the right for the names, which are drawn outward from the // tip and are not in the coordinates. @@ -75,8 +100,15 @@ window.karyonCanvas = (function () { function size() { var ratio = window.devicePixelRatio || 1; - var wide = canvas.clientWidth || 1; - var tall = canvas.clientHeight || 1; + // A canvas with no size in the stylesheet takes its width from its own + // backing store, so writing the store below moves the box that was just + // measured and the two chase each other: a page that forgot the CSS grew + // one to sixteen million pixels a side in a handful of frames. The stop + // costs nothing on a page that sized its canvas, and turns a hang into a + // picture that is merely wrong on one that did not. + var most = 8192 / ratio; + var wide = Math.min(canvas.clientWidth || 1, most); + var tall = Math.min(canvas.clientHeight || 1, most); if (canvas.width !== Math.round(wide * ratio) || canvas.height !== Math.round(tall * ratio)) { canvas.width = Math.round(wide * ratio); canvas.height = Math.round(tall * ratio); @@ -84,6 +116,100 @@ window.karyonCanvas = (function () { return { wide: wide, tall: tall, ratio: ratio }; } + // Where the rail is, or null on a canvas too narrow to give it the room. + function rail(box) { + if (!view || box.wide < RAIL_LEAST) return null; + return { x0: box.wide - RAIL_WIDE, wide: RAIL_WIDE, tall: box.tall }; + } + + // The rail holds every row there is, so a row maps to a height on it and a + // height back to a row. This is the only arithmetic the rail needs, and it + // is the inverse of itself. + function rowAtHeight(py, box) { + var b = view.bounds; + var span = b.highY - b.lowY + 1; + return b.lowY + (py / Math.max(1, box.tall)) * span; + } + + function heightOfRow(row, box) { + var b = view.bounds; + var span = b.highY - b.lowY + 1; + return ((row - b.lowY) / span) * box.tall; + } + + // The whole tree, thinned to the rail's own height. Built once, and again + // only if the canvas changes height or the page changes colour, because it + // is the same picture at a different resolution and not a second opinion + // about the tree: it comes out of the same `select` the main view is drawn + // from. + // + // The picture never moves, so it is drawn once onto a canvas of its own and + // stamped from there. Re-stroking it every frame was the floor under every + // gesture: on a tree with six hundred thousand nodes it cost ten + // milliseconds a frame even with fifty branches on screen. + function overview(box, theme) { + if (view.overviewFor === box.tall && view.overviewInk === theme.faint) { + return view.overview; + } + var picked = select([[0, view.byRow.length]], RAIL_WIDE, box.tall, + view.bounds.highY - view.bounds.lowY + 1, { + child: new Uint32Array(1 << 13), + up: new Uint32Array(1 << 13), + }); + var seen = { + child: picked.child, + up: picked.up, + count: picked.count, + reach: spread(picked), + }; + seen.plate = plate(box, theme, seen); + view.overview = seen; + view.overviewFor = box.tall; + view.overviewInk = theme.faint; + return seen; + } + + // A canvas holding just the rail's silhouette, or null where there is no + // document to make one from, which is where the checks run. Drawing to the + // main canvas is the fallback and it is the same code either way. + function plate(box, theme, seen) { + var papers = canvas.ownerDocument; + if (!papers || !papers.createElement) return null; + var sheet = papers.createElement("canvas"); + var ratio = box.ratio; + sheet.width = Math.max(1, Math.round(RAIL_WIDE * ratio)); + sheet.height = Math.max(1, Math.round(box.tall * ratio)); + var ink = sheet.getContext("2d"); + if (!ink) return null; + ink.setTransform(ratio, 0, 0, ratio, 0, 0); + strokeOverview(ink, theme, box, { x0: 0, wide: RAIL_WIDE }, seen); + return sheet; + } + + // The silhouette itself, given somewhere to put it. + function strokeOverview(ink, theme, box, strip, seen) { + var inner = RAIL_WIDE - RAIL_PAD * 2; + var reach = seen.reach; + var acrossX = reach.highX - reach.lowX || 1; + var atX = function (value) { + return strip.x0 + RAIL_PAD + ((value - reach.lowX) / acrossX) * inner; + }; + var atY = function (row) { return heightOfRow(row, box); }; + ink.strokeStyle = theme.faint; + ink.lineWidth = 1; + ink.beginPath(); + for (var each = 0; each < seen.count; each++) { + var node = seen.child[each]; + var over = seen.up[each]; + var y = atY(view.y[node]); + ink.moveTo(atX(view.x[over]), y); + ink.lineTo(atX(view.x[node]), y); + ink.moveTo(atX(view.x[over]), y); + ink.lineTo(atX(view.x[over]), atY(view.y[over])); + } + ink.stroke(); + } + // The first row at or after `value`, by binary search over the sorted rows. function firstRow(value) { var low = 0, high = view.byRow.length; @@ -95,55 +221,491 @@ window.karyonCanvas = (function () { return low; } - function paint(theme) { - if (!view) return { drawn: 0, skipped: 0 }; + // The disc's own way of saying where you are. A circle has no top and + // bottom, so the rail's bar does not fit it: what fits is the whole disc, + // small, in a corner, with the window drawn on it. Same idea, shape + // following the projection. + function dialPlate(theme) { + var papers = canvas.ownerDocument; + var span = DIAL_SIDE / 2 / 1.1; + var picked = select([[0, view.byRow.length]], DIAL_SIDE, + Math.max(64, Math.round(Math.PI * 2 * span)), terminals(), + { child: new Uint32Array(1 << 12), up: new Uint32Array(1 << 12) }); + var held = { child: picked.child, up: picked.up, count: picked.count, span: span }; + if (!papers || !papers.createElement) return held; + var sheet = papers.createElement("canvas"); + var ratio = window.devicePixelRatio || 1; + sheet.width = Math.round(DIAL_SIDE * ratio); + sheet.height = Math.round(DIAL_SIDE * ratio); + var ink = sheet.getContext("2d"); + if (!ink) return held; + ink.setTransform(ratio, 0, 0, ratio, 0, 0); + strokeDisc(ink, theme, held, DIAL_SIDE / 2, DIAL_SIDE / 2, span); + held.plate = sheet; + return held; + } + + function strokeDisc(ink, theme, held, midX, midY, span) { + ink.strokeStyle = theme.faint; + ink.lineWidth = 1; + ink.beginPath(); + for (var each = 0; each < held.count; each++) { + var node = held.child[each]; + var over = held.up[each]; + var turn = angleOf(view.y[node]); + var back = angleOf(view.y[over]); + var inward = radiusOf(view.x[over]) * span; + var out = radiusOf(view.x[node]) * span; + var alongX = midX + Math.cos(turn) * inward; + var alongY = midY + Math.sin(turn) * inward; + if ((inward * (turn - back) * (turn - back)) / 8 < 0.5) { + ink.moveTo(midX + Math.cos(back) * inward, midY + Math.sin(back) * inward); + ink.lineTo(alongX, alongY); + } else { + ink.moveTo(midX + Math.cos(back) * inward, midY + Math.sin(back) * inward); + ink.arc(midX, midY, inward, back, turn, turn < back); + } + ink.moveTo(alongX, alongY); + ink.lineTo(midX + Math.cos(turn) * out, midY + Math.sin(turn) * out); + } + ink.stroke(); + } + + function dial(box) { + if (!view || box.wide < RAIL_LEAST) return null; + return { + x0: box.wide - DIAL_SIDE - DIAL_EDGE, + y0: box.tall - DIAL_SIDE - DIAL_EDGE, + side: DIAL_SIDE, + }; + } + + function paintDial(ctx, theme, box, arc) { + var spot = dial(box); + if (!spot) return null; + if (!view.dial || view.dialInk !== theme.faint) { + view.dial = dialPlate(theme); + view.dialInk = theme.faint; + } + var held = view.dial; + + ctx.fillStyle = theme.plate; + ctx.fillRect(spot.x0, spot.y0, spot.side, spot.side); + ctx.strokeStyle = theme.frame; + ctx.lineWidth = 1; + ctx.strokeRect(spot.x0 + 0.5, spot.y0 + 0.5, spot.side - 1, spot.side - 1); + + var midX = spot.x0 + spot.side / 2; + var midY = spot.y0 + spot.side / 2; + if (held.plate) ctx.drawImage(held.plate, spot.x0, spot.y0, spot.side, spot.side); + else strokeDisc(ctx, theme, held, midX, midY, held.span); + + // The window, on the disc. At the far end of a zoom it is a speck, so it + // is held to a size a reader can see and a hand can catch, the way the + // rail holds its bar. + var seat = discBox(box); + var wide = Math.max(MARK_LEAST, 2 * seat.halfW * held.span); + var tall = Math.max(MARK_LEAST, 2 * disc.half * held.span); + var left = midX + (disc.cx - seat.halfW) * held.span; + var top = midY + (disc.cy - disc.half) * held.span; + if (wide > spot.side) { left = spot.x0; wide = spot.side; } + if (tall > spot.side) { top = spot.y0; tall = spot.side; } + if (left < spot.x0) left = spot.x0; + if (top < spot.y0) top = spot.y0; + if (left + wide > spot.x0 + spot.side) left = spot.x0 + spot.side - wide; + if (top + tall > spot.y0 + spot.side) top = spot.y0 + spot.side - tall; + ctx.fillStyle = theme.window; + ctx.fillRect(left, top, wide, tall); + ctx.strokeStyle = theme.edge; + ctx.strokeRect(left + 0.5, top + 0.5, Math.max(1, wide - 1), Math.max(1, tall - 1)); + + return { + x0: spot.x0, y0: spot.y0, side: spot.side, + top: top, left: left, deep: tall, wide: wide, + drawn: held.count, + arc: arc ? arc.span : Math.PI * 2, + }; + } + + // ----------------------------------------------------------------- disc + + // The circular projection is the rectangular one in polar coordinates: the + // same rows, with depth becoming radius and row becoming angle. The crate + // says exactly that in radial.rs, and this is its arithmetic rather than + // another one that looks similar, so the canvas and the figure agree. The + // three constants are the crate's own defaults: the sweep starts at the top + // of the circle, goes all the way round, and leaves a hole in the middle + // eight percent of the way out. + var DISC_START = -Math.PI / 2; + var DISC_SWEEP = Math.PI * 2; + var DISC_HOLE = 0.08; + + function terminals() { + var b = view.bounds; + return Math.max(1, b.highY - b.lowY + 1); + } + + function angleOf(row) { + var b = view.bounds; + var many = terminals(); + if (many <= 1) return DISC_START; + return DISC_START + (DISC_SWEEP * (row - b.lowY)) / many; + } + + function rowAtAngle(angle) { + var b = view.bounds; + return b.lowY + ((angle - DISC_START) / DISC_SWEEP) * terminals(); + } + + // Depth as a fraction of the way from the hole to the rim, which is what + // the crate's `radius` does with the scene's own minimum and maximum. + function radiusOf(depth) { + var b = view.bounds; + var across = b.highX - b.lowX; + var part = across > 0 ? (depth - b.lowX) / across : 0; + if (part < 0) part = 0; + if (part > 1) part = 1; + return DISC_HOLE + part * (1 - DISC_HOLE); + } + + // The disc is drawn in a space where the rim is one unit from the middle, + // and the camera is a box over that space rather than a run of rows. A + // circle has no top and bottom to scroll between, so what moves is a + // window over a map. + function discHome() { + disc = { cx: 0, cy: 0, half: 1.1 }; + } + + function discBox(box) { + var scale = box.tall / (2 * disc.half); + return { + scale: scale, + midX: box.wide / 2, + midY: box.tall / 2, + halfW: disc.half * (box.wide / box.tall), + }; + } + + // Which angles the window can see. Null means all of them, which is what a + // window holding the middle of the disc sees however small it is. + function arcInView(box) { + var seat = discBox(box); + var x0 = disc.cx - seat.halfW, x1 = disc.cx + seat.halfW; + var y0 = disc.cy - disc.half, y1 = disc.cy + disc.half; + if (x0 <= 0 && x1 >= 0 && y0 <= 0 && y1 >= 0) return null; + var bearings = []; + var steps = 24; + for (var i = 0; i <= steps; i++) { + var t = i / steps; + bearings.push(Math.atan2(y0, x0 + (x1 - x0) * t)); + bearings.push(Math.atan2(y1, x0 + (x1 - x0) * t)); + bearings.push(Math.atan2(y0 + (y1 - y0) * t, x0)); + bearings.push(Math.atan2(y0 + (y1 - y0) * t, x1)); + } + bearings.sort(function (a, b) { return a - b; }); + // The widest gap between one angle and the next is the part of the circle + // the window cannot see, so what it can see is everything else. + var gap = bearings[0] + Math.PI * 2 - bearings[bearings.length - 1]; + var at = bearings.length - 1; + for (var k = 0; k + 1 < bearings.length; k++) { + var wideEnough = bearings[k + 1] - bearings[k]; + if (wideEnough > gap) { gap = wideEnough; at = k; } + } + var from = bearings[(at + 1) % bearings.length]; + var to = bearings[at]; + if (to < from) to += Math.PI * 2; + return { from: from, to: to, span: to - from }; + } + + // The rows those angles stand for, as spans of `byRow`. A window that + // straddles the place where the circle's ends meet gets two. + function runsForArc(arc) { + var everything = [[0, view.byRow.length]]; + if (!arc) return everything; + var b = view.bounds; + var many = terminals(); + var first = rowAtAngle(arc.from) - 1; + var last = rowAtAngle(arc.to) + 1; + if (last - first >= many) return everything; + if (first < b.lowY) { + return [[firstRow(first + many), view.byRow.length], [0, firstRow(last)]]; + } + if (last > b.highY) { + return [[firstRow(first), view.byRow.length], [0, firstRow(last - many)]]; + } + return [[firstRow(first), firstRow(last)]]; + } + + // The disc, drawn. A branch is the same elbow the rectangular view draws, + // bent: an arc at the parent's radius from the parent's angle round to the + // child's, then a straight run outward at the child's angle. + function paintDisc(theme) { var box = size(); var ctx = canvas.getContext("2d"); ctx.setTransform(box.ratio, 0, 0, box.ratio, 0, 0); ctx.clearRect(0, 0, box.wide, box.tall); - var spanX = camera.x1 - camera.x0 || 1; - var spanY = camera.y1 - camera.y0 || 1; - var sx = box.wide / spanX; - var sy = box.tall / spanY; - var atX = function (value) { return (value - camera.x0) * sx; }; - var atY = function (value) { return (value - camera.y0) * sy; }; + var seat = discBox(box); + var midX = seat.midX - disc.cx * seat.scale; + var midY = seat.midY - disc.cy * seat.scale; + var arc = arcInView(box); + var runs = runsForArc(arc); - var from = firstRow(camera.y0 - 1); - var to = firstRow(camera.y1 + 1); - var wanted = to - from; - // One row per pixel is all a screen can show. Past that they land on each - // other, so they are stepped over. - var stride = Math.max(1, Math.ceil(wanted / Math.max(1, box.tall))); - - // Stepping over rows on its own draws a branch with nothing to hang it - // on: at any zoom where the tree is taller than the canvas, none of the - // kept rows is the parent of another, and what is on screen is a hedge of - // loose horizontal strokes rather than a tree. So every kept row is - // walked up to the root and its ancestors are drawn with it. That is a - // handful of extra nodes, because the walks meet and stop, and it is what - // puts a trunk on the picture. + // A screen can tell apart as many angles as the widest circle it can see + // has pixels along the part of it in view, which is the circular reading + // of one row per pixel. The widest circle it can see is not always the + // rim: zoomed in on the middle of the disc the rim is off the canvas + // entirely, and measuring against it asked for six thousand samples of a + // region that can hold a few hundred. + var farX = Math.max(Math.abs(disc.cx - seat.halfW), Math.abs(disc.cx + seat.halfW)); + var farY = Math.max(Math.abs(disc.cy - disc.half), Math.abs(disc.cy + disc.half)); + var outermost = Math.min(1, Math.sqrt(farX * farX + farY * farY)); + var rim = (arc ? arc.span : Math.PI * 2) * outermost * seat.scale; + var budget = Math.max(64, Math.min(4000, Math.round(rim))); + var limits = { + stop: inside > 0 ? function (node) { return radiusOf(view.x[node]) < inside; } : null, + skip: outermost < 1 + ? function (node) { return radiusOf(view.x[node]) > outermost; } + : null, + }; + // Every walk heads inward, so what ends it is being further in than + // anything the window can see. That is the distance from the middle of + // the disc to the nearest corner or edge of the window, and it is zero + // when the window holds the middle. Stopping on "off the canvas" instead + // was wrong in a way worth writing down: a tip outside the window walking + // inward would be cut off before it reached the part of its own lineage + // that is inside it, and the picture came out with 1,423 of its 1,430 + // branches off screen. + var seatX0 = disc.cx - seat.halfW, seatX1 = disc.cx + seat.halfW; + var seatY0 = disc.cy - disc.half, seatY1 = disc.cy + disc.half; + var nearX = Math.max(seatX0, Math.min(0, seatX1)); + var nearY = Math.max(seatY0, Math.min(0, seatY1)); + var inside = Math.sqrt(nearX * nearX + nearY * nearY); + var picked = select(runs, seat.scale, budget, terminals(), view.shown, limits); + view.shown = { child: picked.child, up: picked.up }; + view.picked = picked.count; + + ctx.strokeStyle = theme.branch; + ctx.lineWidth = 1; + ctx.beginPath(); + var drawn = 0; + for (var each = 0; each < picked.count; each++) { + var node = picked.child[each]; + var over = picked.up[each]; + var turn = angleOf(view.y[node]); + var back = angleOf(view.y[over]); + var out = radiusOf(view.x[node]) * seat.scale; + var inward = radiusOf(view.x[over]) * seat.scale; + var alongX = midX + Math.cos(turn) * inward; + var alongY = midY + Math.sin(turn) * inward; + // An arc, unless it is too flat to tell from a line. What decides that + // is how far the arc bows away from its own chord, which for a small + // turn is the radius times the square of it over eight: near the rim a + // branch between neighbouring tips bows by a thousandth of a pixel, and + // asking the canvas for an arc there costs nine times what a line does + // and draws the same thing. + if ((inward * (turn - back) * (turn - back)) / 8 < 0.5) { + ctx.moveTo(midX + Math.cos(back) * inward, midY + Math.sin(back) * inward); + ctx.lineTo(alongX, alongY); + } else { + ctx.moveTo(midX + Math.cos(back) * inward, midY + Math.sin(back) * inward); + ctx.arc(midX, midY, inward, back, turn, turn < back); + } + ctx.moveTo(alongX, alongY); + ctx.lineTo(midX + Math.cos(turn) * out, midY + Math.sin(turn) * out); + drawn += 1; + } + ctx.stroke(); + + var labels = 0; + // Names, once a name has a whole row of pixels of rim to itself. + var perTip = (Math.PI * 2 * seat.scale) / terminals(); + if (perTip >= LABEL_ROOM && picked.stride === 1) { + ctx.fillStyle = theme.muted; + ctx.font = Math.min(13, Math.max(9, perTip * 0.72)) + "px " + theme.font; + ctx.textBaseline = "middle"; + for (var run = 0; run < runs.length; run++) { + for (var i = runs[run][0]; i < runs[run][1]; i++) { + var leaf = view.byRow[i]; + if (!view.length[leaf]) continue; + var text = nameOf(leaf); + if (!text) continue; + var where = angleOf(view.y[leaf]); + var edge = radiusOf(view.x[leaf]) * seat.scale + 4; + var atX = midX + Math.cos(where) * edge; + var atY = midY + Math.sin(where) * edge; + if (atX < -40 || atX > box.wide + 40 || atY < -20 || atY > box.tall + 20) continue; + ctx.textAlign = Math.cos(where) < 0 ? "right" : "left"; + ctx.fillText(text, atX, atY); + labels += 1; + } + } + ctx.textAlign = "left"; + } + + var marked = box.wide >= RAIL_LEAST ? paintDial(ctx, theme, box, arc) : null; + return { + drawn: drawn, + skipped: 0, + labels: labels, + stride: picked.stride, + rowsInView: runs.reduce(function (sum, run) { return sum + run[1] - run[0]; }, 0), + rail: marked, + }; + } + + // ------------------------------------------------------------ selection + + // The branches to draw for a run of rows in a box `wide` by `tall`, as a + // run of child and parent pairs. + // + // One row per pixel is all a screen can show, so past that they are stepped + // over. Stepping alone draws a branch with nothing to hang it on: at any + // zoom where the tree is taller than the canvas none of the kept rows is + // the parent of another, and what is on screen is a hedge of loose + // horizontal strokes rather than a tree. So every kept row is walked up to + // the root and its ancestors are drawn with it. + // + // On most trees the walks meet almost at once and that is a handful of + // extra branches. On a ladder it is not: every tip hangs off the spine, so + // one walk is half the tree, and drawing a hundred and twenty thousand tip + // caterpillar cost a hundred and fifty eight milliseconds a frame and did + // not get cheaper when it was zoomed into. So the walk steps over an + // ancestor that would be drawn inside the same pixel as the branch below + // it, which is the same rule the rest of this crate uses along the other + // axis: past one point per pixel the extra ones land on each other. The + // ink is the same and the work is bounded by what a screen can hold. + // + // Everything drawn anywhere comes through here, which is what stops one + // part of the canvas disagreeing with another about the shape of the tree. + // `runs` is one or more [first, last) spans of rows. A circle can put the + // rows in view either side of where its ends meet, and that is two spans of + // one tree rather than two trees. + function select(runs, wide, tall, spanY, store, limits) { + var whole = 0; + for (var span = 0; span < runs.length; span++) whole += runs[span][1] - runs[span][0]; + var stride = Math.max(1, Math.ceil(whole / Math.max(1, tall))); view.visit += 1; var visit = view.visit; var seen = view.seen; - var shown = view.shown; + var x = view.x; + var y = view.y; + var parent = view.parent; + var child = store.child; + var up = store.up; var count = 0; var sampled = 0; - for (var at = from; at < to; at += stride) { + + // The scale the pixel test uses, worked out before the walk rather than + // after it. The walk always reaches the root, so the left edge is the + // root; and a parent is never deeper than its child, so the right edge is + // the deepest of the rows sampled. Neither needs the walk to have run. + var lowX = x[view.root]; + var highX = lowX; + for (var run = 0; run < runs.length; run++) { + for (var look = runs[run][0]; look < runs[run][1]; look += stride) { + var seenX = x[view.byRow[look]]; + if (seenX > highX) highX = seenX; + } + } + // Both scales are the ones this drawing will actually use. An earlier + // version measured rows against the whole tree instead of against the + // window, which at a deep zoom is a scale hundreds of times too coarse: + // it stepped over ancestors that were far apart on screen and moved one + // pixel in a hundred. Measured that way the ink came out 1.1 percent + // different; measured this way it does not move. + var acrossX = Math.max(1, wide) / (highX - lowX || 1); + var downY = Math.max(1, tall) / (spanY || 1); + var columnOf = function (node) { return ((x[node] - lowX) * acrossX) | 0; }; + var rowOf = function (node) { return (y[node] * downY) | 0; }; + + for (var pass = 0; pass < runs.length; pass++) { + for (var at = runs[pass][0]; at < runs[pass][1]; at += stride) { var walk = view.byRow[at]; sampled += 1; while (walk !== 0xffffffff && seen[walk] !== visit) { seen[walk] = visit; - if (count === shown.length) { - var wider = new Uint32Array(shown.length * 2); - wider.set(shown); - shown = view.shown = wider; + var over = parent[walk]; + if (over === 0xffffffff) break; + while ( + parent[over] !== 0xffffffff && + seen[over] !== visit && + columnOf(over) === columnOf(walk) && + rowOf(over) === rowOf(walk) + ) { + seen[over] = visit; + over = parent[over]; + } + if (count === child.length) { + var wideChild = new Uint32Array(child.length * 2); + wideChild.set(child); + child = wideChild; + var wideUp = new Uint32Array(up.length * 2); + wideUp.set(up); + up = wideUp; + } + // A branch further out than anything the window can see is walked + // past rather than drawn. The one that crosses into view is kept, so + // the picture runs off the edge rather than stopping short of it. + if (!limits || !limits.skip || !limits.skip(over)) { + child[count] = walk; + up[count] = over; + count += 1; } - shown[count] = walk; - count += 1; - walk = view.parent[walk]; + // And once a walk is further in than anything the window can see, + // there is nothing left for it to draw. Every walk heads inward, so + // this is where it ends. + if (limits && limits.stop && limits.stop(over)) break; + walk = over; } } + } + return { child: child, up: up, count: count, stride: stride, sampled: sampled }; + } + + // The span in x that holds a selection, which is the root on the left and + // the deepest branch in it on the right. Both endpoints of every branch, + // because a parent stepped over by the pixel test is still drawn to. + function spread(picked) { + var lowX = Infinity, highX = -Infinity; + for (var scan = 0; scan < picked.count; scan++) { + var a = view.x[picked.child[scan]]; + var c = view.x[picked.up[scan]]; + if (a < lowX) lowX = a; + if (c < lowX) lowX = c; + if (a > highX) highX = a; + if (c > highX) highX = c; + } + if (!(highX > lowX)) return { lowX: view.bounds.lowX, highX: view.bounds.highX }; + return { lowX: lowX, highX: highX }; + } + + function paint(theme) { + if (!view) return { drawn: 0, skipped: 0 }; + if (round) return paintDisc(theme); + var box = size(); + var ctx = canvas.getContext("2d"); + ctx.setTransform(box.ratio, 0, 0, box.ratio, 0, 0); + ctx.clearRect(0, 0, box.wide, box.tall); + + var strip = rail(box); + // The tree draws into what is left when the rail has taken its width. + var wide = strip ? strip.x0 : box.wide; + + var spanX = camera.x1 - camera.x0 || 1; + var spanY = camera.y1 - camera.y0 || 1; + var sx = wide / spanX; + var sy = box.tall / spanY; + var atX = function (value) { return (value - camera.x0) * sx; }; + var atY = function (value) { return (value - camera.y0) * sy; }; + + var from = firstRow(camera.y0 - 1); + var to = firstRow(camera.y1 + 1); + var wanted = to - from; + var picked = select([[from, to]], wide, box.tall, spanY, view.shown); + view.shown = { child: picked.child, up: picked.up }; + var count = picked.count; + var stride = picked.stride; // The depth axis follows what is drawn rather than being zoomed alongside // the rows. Zooming both narrowed the window in x as well until it held @@ -152,22 +714,12 @@ window.karyonCanvas = (function () { // always reaches the root, the left edge is the root and the right edge // is the deepest tip on screen, so the axis stands still while it is // panned and only gives ground back as a clade is entered. - var lowX = Infinity, highX = -Infinity; - for (var scan = 0; scan < count; scan++) { - var it = shown[scan]; - if (view.x[it] < lowX) lowX = view.x[it]; - if (view.x[it] > highX) highX = view.x[it]; - } - if (!(highX > lowX)) { - var b = view.bounds; - lowX = b.lowX; - highX = b.highX; - } - var margin = (highX - lowX) * 0.28; - camera.x0 = lowX - margin * 0.05; - camera.x1 = highX + margin; + var reach = spread(picked); + var margin = (reach.highX - reach.lowX) * 0.28; + camera.x0 = reach.lowX - margin * 0.05; + camera.x1 = reach.highX + margin; spanX = camera.x1 - camera.x0 || 1; - sx = box.wide / spanX; + sx = wide / spanX; view.picked = count; @@ -176,18 +728,16 @@ window.karyonCanvas = (function () { ctx.beginPath(); var drawn = 0; for (var each = 0; each < count; each++) { - var node = shown[each]; - var up = view.parent[node]; - if (up === 0xffffffff) continue; + var node = picked.child[each]; + var over = picked.up[each]; var y = atY(view.y[node]); var x1 = atX(view.x[node]); - var x0 = atX(view.x[up]); + var x0 = atX(view.x[over]); ctx.moveTo(x0, y); ctx.lineTo(x1, y); // The elbow up to the parent's own row. - var py = atY(view.y[up]); ctx.moveTo(x0, y); - ctx.lineTo(x0, py); + ctx.lineTo(x0, atY(view.y[over])); drawn += 1; } ctx.stroke(); @@ -200,16 +750,64 @@ window.karyonCanvas = (function () { ctx.fillStyle = theme.muted; ctx.font = Math.min(13, Math.max(9, perRow * 0.72)) + "px " + theme.font; ctx.textBaseline = "middle"; + // Names stop where the rail begins. Without the stop they run under it + // and the silhouette is drawn over their tails, which reads as a name + // that has been cut rather than as a name behind something. for (var i = from; i < to; i++) { var leaf = view.byRow[i]; if (!view.length[leaf]) continue; var text = nameOf(leaf); if (!text) continue; - ctx.fillText(text, atX(view.x[leaf]) + 4, atY(view.y[leaf])); + var left = atX(view.x[leaf]) + 4; + if (left + ctx.measureText(text).width > wide) continue; + ctx.fillText(text, left, atY(view.y[leaf])); labels += 1; } } - return { drawn: drawn, skipped: wanted - sampled, labels: labels, stride: stride, rowsInView: wanted }; + var marked = strip ? paintRail(ctx, theme, box, strip) : null; + + return { + drawn: drawn, + skipped: wanted - picked.sampled, + labels: labels, + stride: stride, + rowsInView: wanted, + rail: marked, + }; + } + + // The rail: the whole tree at the height of the canvas, with the rows on + // screen marked on it. Its own picture never moves, so what a reader + // follows is the mark travelling down a shape that stays put. + function paintRail(ctx, theme, box, strip) { + var seen = overview(box, theme); + + // The edge it stands behind, so the rail reads as a margin and not as + // more tree. + ctx.strokeStyle = theme.frame; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(strip.x0 + 0.5, 0); + ctx.lineTo(strip.x0 + 0.5, box.tall); + ctx.stroke(); + + if (seen.plate) ctx.drawImage(seen.plate, strip.x0, 0, strip.wide, box.tall); + else strokeOverview(ctx, theme, box, strip, seen); + + // The rows on screen. Three rows out of two million is a thousandth of a + // pixel, so the mark is held to a size a reader can see and a hand can + // catch, and it is kept on the rail rather than allowed to hang off it. + var top = heightOfRow(camera.y0, box); + var foot = heightOfRow(camera.y1, box); + var deep = Math.max(MARK_LEAST, foot - top); + if (top + deep > box.tall) top = box.tall - deep; + if (top < 0) top = 0; + ctx.fillStyle = theme.window; + ctx.fillRect(strip.x0 + 1, top, strip.wide - 1, deep); + ctx.strokeStyle = theme.edge; + ctx.strokeRect(strip.x0 + 1.5, top + 0.5, strip.wide - 2, Math.max(1, deep - 1)); + + return { x0: strip.x0, wide: strip.wide, top: top, deep: deep, drawn: seen.count }; } // --------------------------------------------------------------- camera @@ -217,6 +815,22 @@ window.karyonCanvas = (function () { // Only the rows. The depth follows them, in `paint`. function zoomAt(px, py, factor) { var box = size(); + if (round) { + // Keep whatever is under the hand under the hand, which is what a map + // does and what a run of rows cannot do because it has only one axis. + var seat = discBox(box); + var atX = disc.cx + (px - seat.midX) / seat.scale; + var atY = disc.cy + (py - seat.midY) / seat.scale; + var half = disc.half / factor; + if (half > 1.4) half = 1.4; + if (half < 1e-5) half = 1e-5; + disc.half = half; + var after = discBox(box); + disc.cx = atX - (px - after.midX) / after.scale; + disc.cy = atY - (py - after.midY) / after.scale; + settleDisc(); + return true; + } var fy = py / box.tall; var atY = camera.y0 + (camera.y1 - camera.y0) * fy; var tall = (camera.y1 - camera.y0) / factor; @@ -231,26 +845,80 @@ window.karyonCanvas = (function () { return true; } - // Rows only. A drag sideways is taken and dropped: the depth axis is fitted - // to what is drawn on every paint, so nudging it would be undone before the - // frame was on screen. - function panBy(dx, dy) { - var box = size(); - var span = camera.y1 - camera.y0; - var byY = (dy / box.tall) * span; + // Half a screen of empty is as far as the rows go. Without a stop the tree + // can be pushed off the canvas altogether, and white with no rows on it + // gives a reader nothing to drag back by. + function settle(y0, span) { var b = view.bounds; - // Half a screen of empty is as far as a drag goes. Without a stop the - // tree can be pushed off the canvas altogether, and white with no rows on - // it gives a reader nothing to drag back by. var first = b.lowY - span * 0.5; var last = b.highY - span * 0.5; - var y0 = camera.y0 - byY; if (y0 < first) y0 = first; if (y0 > last) y0 = last; camera.y0 = y0; camera.y1 = y0 + span; } + // Rows only. A drag sideways is taken and dropped: the depth axis is fitted + // to what is drawn on every paint, so nudging it would be undone before the + // frame was on screen. + function panBy(dx, dy) { + var box = size(); + if (round) { + var seat = discBox(box); + disc.cx -= dx / seat.scale; + disc.cy -= dy / seat.scale; + settleDisc(); + return; + } + var span = camera.y1 - camera.y0; + settle(camera.y0 - (dy / box.tall) * span, span); + } + + // The disc can be pushed until the rim is off the canvas, but not until + // there is nothing left to aim at. + function settleDisc() { + if (disc.cx < -1.2) disc.cx = -1.2; + if (disc.cx > 1.2) disc.cx = 1.2; + if (disc.cy < -1.2) disc.cy = -1.2; + if (disc.cy > 1.2) disc.cy = 1.2; + } + + // Is this point, in canvas pixels, on the small picture of the whole tree + // rather than on the tree itself? The rail in rows, the dial on the disc. + function onMap(px, py) { + if (!view) return false; + var box = size(); + if (round) { + var spot = dial(box); + return ( + !!spot && + px >= spot.x0 && px <= spot.x0 + spot.side && + py >= spot.y0 && py <= spot.y0 + spot.side + ); + } + var strip = rail(box); + return !!strip && px >= strip.x0; + } + + // Put the rows on screen where this height on the rail points, keeping how + // many of them there are. A click and a drag are the same gesture: the + // window follows the hand rather than being nudged by it. + function jumpTo(px, py) { + if (!view) return; + var box = size(); + if (round) { + var spot = dial(box); + var held = view.dial; + if (!spot || !held) return; + disc.cx = (px - (spot.x0 + spot.side / 2)) / held.span; + disc.cy = (py - (spot.y0 + spot.side / 2)) / held.span; + settleDisc(); + return; + } + var span = camera.y1 - camera.y0; + settle(rowAtHeight(py, box) - span / 2, span); + } + // Puts a named tip in the middle, without changing how much is on screen. function goTo(name) { if (!view) return false; @@ -269,8 +937,18 @@ window.karyonCanvas = (function () { // view would be asked for. function looking() { if (!view) return null; - var from = firstRow(camera.y0); - var to = firstRow(camera.y1); + var from, to; + if (round) { + var runs = runsForArc(arcInView(size())); + // Two runs mean the window straddles where the circle's ends meet, and + // the pair a reader wants named is the whole of what is on screen. + from = runs[0][0]; + to = runs[runs.length - 1][1]; + if (runs.length > 1) { from = 0; to = view.byRow.length; } + } else { + from = firstRow(camera.y0); + to = firstRow(camera.y1); + } // In from each end rather than along the whole run. Walking every row in // view and decoding its name is fine on a screenful and is not fine on // the first sight of a million tip tree, where the whole file is in view: @@ -293,6 +971,39 @@ window.karyonCanvas = (function () { home: home, zoomAt: zoomAt, panBy: panBy, + onMap: onMap, + jumpTo: jumpTo, + // Which projection is being looked through. The layout does not change: + // the same rows and depths become angles and radii. + shape: function (wantRound) { + if (!view || wantRound === round) return round; + round = !!wantRound; + home(); + return round; + }, + isRound: function () { return round; }, + // Where a node sits on the canvas, in the projection being looked + // through. The one place that answers it, so a check of the disc can ask + // whether the canvas agrees with the crate's own arithmetic. + where: function (node) { + if (!view) return null; + var box = size(); + if (round) { + var seat = discBox(box); + var out = radiusOf(view.x[node]) * seat.scale; + var turn = angleOf(view.y[node]); + return { + x: seat.midX - disc.cx * seat.scale + Math.cos(turn) * out, + y: seat.midY - disc.cy * seat.scale + Math.sin(turn) * out, + }; + } + var strip = rail(box); + var wide = strip ? strip.x0 : box.wide; + return { + x: ((view.x[node] - camera.x0) / (camera.x1 - camera.x0 || 1)) * wide, + y: ((view.y[node] - camera.y0) / (camera.y1 - camera.y0 || 1)) * box.tall, + }; + }, goTo: goTo, looking: looking, loaded: function () { return !!view; }, @@ -300,7 +1011,18 @@ window.karyonCanvas = (function () { // What the last paint put on the canvas, and the depth it fitted them // into. Both are answers about the picture rather than about the tree, // which is what a check of the picture needs to ask. - shown: function () { return view ? view.shown.subarray(0, view.picked || 0) : new Uint32Array(0); }, + // The branches the last paint put on the canvas, as child and parent + // pairs. An answer about the picture rather than about the tree, which is + // what a check of the picture needs to ask. + shown: function () { + if (!view) return { child: new Uint32Array(0), up: new Uint32Array(0), count: 0 }; + var held = view.picked || 0; + return { + child: view.shown.child.subarray(0, held), + up: view.shown.up.subarray(0, held), + count: held, + }; + }, depth: function () { return { x0: camera.x0, x1: camera.x1 }; }, }; } diff --git a/docs/assets/tree-viewer.js b/docs/assets/tree-viewer.js index 6eb69ed..3146046 100644 --- a/docs/assets/tree-viewer.js +++ b/docs/assets/tree-viewer.js @@ -106,6 +106,20 @@ var dark = K.dark(); theme.branch = dark ? "#e6edf3" : "#1b1f23"; theme.muted = dark ? "#aab4c0" : "#4b5563"; + // The rail stands behind the tree, so its ink is quieter than the tree's. + // Quieter is not invisible: the first pair tried here measured 1.96 to 1 on + // white, and a silhouette nobody can see is the whole point of the rail + // thrown away. These are 3.75 and 4.92. + theme.frame = dark ? "#30363d" : "#d7dbe0"; + theme.faint = dark ? "#79838f" : "#7b8591"; + // The mark is the one thing on the canvas that is not a branch, and it is + // the page's own accent rather than a fourth hue: the same colour the + // working bar uses to say which part of this is live. + theme.window = dark ? "rgba(232, 131, 58, 0.24)" : "rgba(213, 94, 0, 0.18)"; + theme.edge = dark ? "#e8833a" : "#d55e00"; + // Something for the dial to sit on, since the disc behind it would show + // through and the small tree would be read as part of the big one. + theme.plate = dark ? "#161a1d" : "#ffffff"; } // Painted on the spot rather than on a frame callback: a browser does not @@ -132,6 +146,7 @@ var at = painter && painter.looking(); var argv = ["tree:1-1", "--tree", tree.name]; if (cladogram) argv = argv.concat(["--shape", "cladogram"]); + if (painter && painter.isRound()) argv = argv.concat(["--projection", "circular"]); if (at && at.first && at.last && at.first !== at.last) { argv = argv.concat(["--focus", at.first + "," + at.last]); } @@ -167,43 +182,104 @@ function hand() { var dragging = false; + var scrubbing = false; + // Which pointer owns the gesture. Without it a second finger overwrites the + // first one's mode and the first release ends the gesture for both. + var owner = null; var from = { x: 0, y: 0 }; + // Where a pointer is, in the canvas's own pixels. The canvas and not the + // box around it: that box carries a one pixel border, and measuring from it + // put every click on the rail a row of pixels low, which at two million + // rows is three thousand of them. + function at(event) { + var box = el.canvas.getBoundingClientRect(); + return { x: event.clientX - box.left, y: event.clientY - box.top }; + } + el.plot.addEventListener( "wheel", function (event) { if (!painter.loaded()) return; event.preventDefault(); - var box = el.plot.getBoundingClientRect(); // A line-mode wheel reports a handful of lines where a pixel-mode one // reports tens of pixels, and treating them alike makes a mouse either // useless or violent next to a trackpad. var step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - painter.zoomAt(event.clientX - box.left, event.clientY - box.top, Math.exp(-step * 0.002)); + var here = at(event); + var box = { wide: el.canvas.clientWidth, tall: el.canvas.clientHeight }; + // On the rail a wheel does what a wheel does to a scrollbar: it moves + // the window on from where it is rather than changing how much of the + // tree is in it. A zoom there would be anchored on the wrong row, since + // the two sides of the canvas are at different scales. + if (painter.onMap(here.x, here.y)) { + // On the rail a wheel does what it does to a scrollbar. On the dial + // there is nothing to scroll, so it zooms the view it stands for, + // about the middle of that view rather than about the dial. + if (painter.isRound()) painter.zoomAt(box.wide / 2, box.tall / 2, Math.exp(-step * 0.002)); + else painter.panBy(0, -step); + } else painter.zoomAt(here.x, here.y, Math.exp(-step * 0.002)); repaint(); }, { passive: false } ); el.plot.addEventListener("pointerdown", function (event) { - if (event.button !== 0 || !painter.loaded()) return; - dragging = true; + if (event.button !== 0 || !painter.loaded() || owner !== null) return; + var here = at(event); + owner = event.pointerId; + // Which half of the canvas the gesture began on decides what it is for + // the whole of its life, so a drag that starts on the rail and wanders + // onto the tree goes on moving the window. + if (painter.onMap(here.x, here.y)) { + scrubbing = true; + painter.jumpTo(here.x, here.y); + repaint(); + } else { + dragging = true; + el.plot.classList.add("tv-dragging"); + } from = { x: event.clientX, y: event.clientY }; el.plot.setPointerCapture(event.pointerId); - el.plot.classList.add("tv-dragging"); }); el.plot.addEventListener("pointermove", function (event) { - if (!dragging) return; - painter.panBy(event.clientX - from.x, event.clientY - from.y); - from = { x: event.clientX, y: event.clientY }; - repaint(); + if (owner !== null && event.pointerId !== owner) return; + if (scrubbing) { + var where = at(event); + painter.jumpTo(where.x, where.y); + repaint(); + return; + } + if (dragging) { + painter.panBy(event.clientX - from.x, event.clientY - from.y); + from = { x: event.clientX, y: event.clientY }; + repaint(); + return; + } + // Not a gesture, just a hand passing over. The cursor says which of the + // two things underneath it would answer. + if (painter.loaded()) { + var over = at(event); + var onMap = painter.onMap(over.x, over.y); + el.plot.classList.toggle("tv-onrail", onMap && !painter.isRound()); + el.plot.classList.toggle("tv-ondial", onMap && painter.isRound()); + } }); - ["pointerup", "pointercancel"].forEach(function (kind) { + // lostpointercapture as well as the two endings: capture can be taken away + // without either of them firing, and that used to strand a pan. Now it + // would strand a mode, which is worse. + ["pointerup", "pointercancel", "lostpointercapture"].forEach(function (kind) { el.plot.addEventListener(kind, function (event) { - if (!dragging) return; + if (owner !== null && event.pointerId !== owner) return; + if (!dragging && !scrubbing) { + owner = null; + return; + } dragging = false; + scrubbing = false; + owner = null; el.plot.classList.remove("tv-dragging"); if (el.plot.hasPointerCapture(event.pointerId)) { el.plot.releasePointerCapture(event.pointerId); @@ -212,8 +288,10 @@ }); el.plot.addEventListener("dblclick", function (event) { - var box = el.plot.getBoundingClientRect(); - painter.zoomAt(event.clientX - box.left, event.clientY - box.top, 2.4); + var here = at(event); + if (!painter.loaded()) return; + if (painter.onMap(here.x, here.y)) return; + painter.zoomAt(here.x, here.y, 2.4); repaint(); }); @@ -301,7 +379,7 @@ function start() { ["plot", "canvas", "search", "rowsOut", "detail", "count", "command", "error", "drop", "app", "file", "paste", "usePaste", "fit", "export", "sheet", - "sheetName", "dropSheet", "shape"].forEach(function (name) { + "sheetName", "dropSheet", "shape", "round"].forEach(function (name) { el[name] = document.getElementById("tv-" + name.toLowerCase()); }); if (!el.canvas) return; @@ -322,6 +400,16 @@ } }); + el.round.addEventListener("click", function () { + // The layout does not change: the same rows and depths become angles and + // radii, so this never asks the program for anything. + var now = painter.shape(!painter.isRound()); + el.round.setAttribute("aria-pressed", String(now)); + el.round.textContent = now ? "Circular" : "Rectangular"; + repaint(); + say(); + }); + el.shape.addEventListener("click", function () { cladogram = !cladogram; el.shape.setAttribute("aria-pressed", String(cladogram)); diff --git a/docs/stylesheets/tree-viewer.css b/docs/stylesheets/tree-viewer.css index d258041..9174020 100644 --- a/docs/stylesheets/tree-viewer.css +++ b/docs/stylesheets/tree-viewer.css @@ -121,6 +121,15 @@ .tv-plot.tv-dragging { cursor: grabbing; } +/* The rail down the right is a scrollbar that shows what it is scrolling + through, so the hand over it says rows and not grab. The dial the circular + view puts in the corner is a map, and a map is moved about rather than + scrolled, so it says so differently. */ +.tv-plot.tv-onrail, +.tv-plot.tv-onrail.tv-dragging { cursor: ns-resize; } +.tv-plot.tv-ondial, +.tv-plot.tv-ondial.tv-dragging { cursor: move; } + .tv-plot canvas { display: block; width: 100%; diff --git a/docs/tree.md b/docs/tree.md index 2b0e5bc..c963f57 100644 --- a/docs/tree.md +++ b/docs/tree.md @@ -34,6 +34,7 @@ hide:
Drag to move, wheel to zoom, double-click to zoom in. The layout is worked out once by +
Drag to move, wheel to zoom, double-click to zoom in. The small picture of the whole tree + says where you are: a rail down the right in the rectangular view, a dial in the corner in the circular one, + with the part you are looking at marked on it. Clicking or dragging there goes straight to that part. + Circular is the same tree in polar coordinates, with depth becoming radius and row becoming angle, so + switching never asks the program for anything. The layout is worked out once by the program and never again: what moves is the window onto it, so a gesture costs a repaint of what is on screen and never a walk over the tree. Export hands the view back to the program and saves karyon's own figure of it.
diff --git a/tests/tree-canvas.test.js b/tests/tree-canvas.test.js index 760dce2..0d2d605 100644 --- a/tests/tree-canvas.test.js +++ b/tests/tree-canvas.test.js @@ -12,17 +12,28 @@ const assert = require("assert"); // draw so a test can ask what is on it. function fakeCanvas(wide, tall) { const strokes = []; + const rects = []; + const texts = []; + const arcs = []; const ctx = { setTransform() {}, clearRect() {}, beginPath() {}, stroke() {}, - moveTo(x, y) { strokes.push(["move", x, y]); }, - lineTo(x, y) { strokes.push(["line", x, y]); }, - fillText() {}, + moveTo(x, y) { strokes.push(["move", x, y, ctx.strokeStyle]); }, + lineTo(x, y) { strokes.push(["line", x, y, ctx.strokeStyle]); }, + fillRect(x, y, w, h) { rects.push({ kind: "fill", x, y, w, h, paint: ctx.fillStyle }); }, + arc(x, y, r, a0, a1) { arcs.push({ x, y, r, a0, a1 }); }, + drawImage() {}, + strokeRect(x, y, w, h) { rects.push({ kind: "stroke", x, y, w, h, paint: ctx.strokeStyle }); }, + fillText(t, x, y) { texts.push({ t, x, y }); }, + measureText(t) { return { width: t.length * 6 }; }, strokeStyle: "", fillStyle: "", lineWidth: 1, font: "", textBaseline: "", }; return { width: wide, height: tall, clientWidth: wide, clientHeight: tall, getContext: () => ctx, strokes, + rects, + texts, + arcs, }; } @@ -75,19 +86,27 @@ function balanced(levels) { }; } -const theme = { branch: "#000", muted: "#666", font: "sans-serif" }; +const theme = { + branch: "#000", muted: "#666", font: "sans-serif", + frame: "#ccc", faint: "#999", window: "rgba(0,0,255,0.15)", edge: "#00f", + plate: "#fff", +}; + +// Wide enough for the rail, and narrow enough to be refused one. +const WIDE = 900; +const NARROW = 380; // ---------------------------------------------------------------- the test // The property: whatever is drawn is a tree. Every branch on screen but the // root's has the branch it hangs from on screen too, so the picture has a trunk // instead of being a hedge of loose strokes. -function drawnSet(placed, tall) { - const canvas = fakeCanvas(420, tall); +function drawnSet(placed, tall, wide) { + const canvas = fakeCanvas(wide || WIDE, tall); const painter = canvasModule.make(canvas); painter.load(placed); const report = painter.paint(theme); - return { painter, report }; + return { painter, report, canvas }; } // A painter that keeps every row it is given, so the test can see what the @@ -123,17 +142,24 @@ check("what is drawn hangs together, at every zoom", () => { for (const tall of [200, 800, 801, 1600]) { const { painter, report } = drawnSet(placed, tall); assert.ok(report.drawn > 0, `nothing drawn at ${tall} px`); - // Ask the painter what it put on the canvas by walking the same selection: - // every stroke pair is a branch, and the branch's own parent must be there. + // Every branch on screen hangs from a branch on screen, or from the root. + // A parent stepped over by the pixel test is not drawn as a branch of its + // own, so what has to be there is the end the branch was redirected to. const shown = painter.shown(); - const on = new Set(shown); + const ends = new Set(); + for (let i = 0; i < shown.count; i++) ends.add(shown.child[i]); let loose = 0; - for (const node of shown) { - if (placed.parent[node] === 0xffffffff) continue; - if (!on.has(placed.parent[node])) loose += 1; + for (let i = 0; i < shown.count; i++) { + const over = shown.up[i]; + if (placed.parent[over] === 0xffffffff) continue; + if (!ends.has(over)) loose += 1; + } + assert.strictEqual(loose, 0, `${loose} of ${shown.count} branches hang from nothing at ${tall} px`); + let root = false; + for (let i = 0; i < shown.count; i++) { + if (placed.parent[shown.up[i]] === 0xffffffff) root = true; } - assert.strictEqual(loose, 0, `${loose} of ${shown.length} branches hang from nothing at ${tall} px`); - assert.ok(on.has(0), `the root is not drawn at ${tall} px`); + assert.ok(root, `nothing reaches the root at ${tall} px`); } }); @@ -160,13 +186,349 @@ check("the depth window holds the root and the deepest tip on screen", () => { const { painter } = drawnSet(placed, 800); const shown = painter.shown(); let low = Infinity, high = -Infinity; - for (const node of shown) { - if (placed.x[node] < low) low = placed.x[node]; - if (placed.x[node] > high) high = placed.x[node]; + for (let i = 0; i < shown.count; i++) { + for (const node of [shown.child[i], shown.up[i]]) { + if (placed.x[node] < low) low = placed.x[node]; + if (placed.x[node] > high) high = placed.x[node]; + } } const window = painter.depth(); assert.ok(window.x0 <= low + 1e-6, "the root is off the left edge"); assert.ok(window.x1 >= high - 1e-6, "the deepest tip on screen is off the right edge"); }); +// A ladder is the shape that used to make the walk to the root cost the whole +// tree on every frame, so it is the shape the bound is checked on. +function ladder(tips) { + const count = 2 * tips - 1; + const x = new Float32Array(count); + const y = new Float32Array(count); + const parent = new Uint32Array(count); + parent[0] = 0xffffffff; + // Node 0 is the root. Each internal node i has a tip and the next internal. + let spine = 0; + let row = 0; + for (let i = 1; i < count; i += 2) { + const tip = i, next = i + 1; + parent[tip] = spine; + x[tip] = x[spine] + 0.01; + y[tip] = row++; + if (next < count) { + parent[next] = spine; + x[next] = x[spine] + 0.005; + spine = next; + } + } + // The spine sits on the mean of what hangs below it, which for a ladder is + // near enough the middle of the rows still to come. + for (let i = count - 1; i >= 0; i--) if (!y[i] && i) y[i] = row / 2; + return { count, x, y, parent, start: new Uint32Array(count), length: new Uint32Array(count), names: new Uint8Array(0) }; +} + +check("a ladder cannot make one frame walk the whole tree", () => { + const placed = ladder(60000); // 119,999 nodes on one spine + const { painter, report } = drawnSet(placed, 800); + assert.ok( + report.drawn < 20000, + `a ladder of ${placed.count} nodes drew ${report.drawn} branches in one frame` + ); + for (let i = 0; i < 40; i++) painter.zoomAt(200, 400, 1.3); + const close = painter.paint(theme); + assert.ok( + close.drawn < 20000, + `zoomed in on a ladder it still drew ${close.drawn} branches` + ); +}); + +check("and the check bites: without the pixel step a ladder walks all of it", () => { + const placed = ladder(60000); + // What the walk would cost with no step over ancestors that share a pixel: + // every tip hangs off the spine, so one walk is half the tree. + const byRow = Uint32Array.from( + Array.from({ length: placed.count }, (_, i) => i).sort((a, b) => placed.y[a] - placed.y[b]) + ); + const seen = new Uint8Array(placed.count); + let reached = 0; + for (let at = 0; at < placed.count; at += Math.ceil(placed.count / 800)) { + let walk = byRow[at]; + while (walk !== 0xffffffff && !seen[walk]) { seen[walk] = 1; reached += 1; walk = placed.parent[walk]; } + } + assert.ok(reached > 50000, `expected the bare walk to reach most of the tree, it reached ${reached}`); +}); + +// ----------------------------------------------------------------- the rail + +check("the rail shows the whole tree, whatever the window holds", () => { + const placed = balanced(14); + const { painter, report } = drawnSet(placed, 800); + assert.ok(report.rail, "no rail on a canvas with room for one"); + const wide = report.rail.drawn; + // Fly all the way in. The rail is the whole tree and must not follow. + for (let i = 0; i < 60; i++) painter.zoomAt(200, 400, 1.3); + const close = painter.paint(theme); + assert.ok(close.drawn < 100, `expected to be deep in, ${close.drawn} branches on screen`); + assert.strictEqual(close.rail.drawn, wide, "the rail changed with the zoom"); +}); + +check("and the check bites: a rail built from the window would follow it", () => { + const placed = balanced(14); + const { painter, report } = drawnSet(placed, 800); + for (let i = 0; i < 60; i++) painter.zoomAt(200, 400, 1.3); + const close = painter.paint(theme); + // What the main view drew is what a window-built rail would have held, and + // it is nothing like the whole tree, which is the point of the check above. + assert.notStrictEqual(close.drawn, report.rail.drawn); +}); + +check("the mark stays big enough to see and to catch", () => { + const placed = balanced(16); // 65,536 tips + const { painter } = drawnSet(placed, 800); + for (let i = 0; i < 80; i++) painter.zoomAt(200, 400, 1.3); + const report = painter.paint(theme); + const rows = painter.looking().rows; + assert.ok(rows < 20, `expected a handful of rows, got ${rows}`); + assert.ok( + report.rail.deep >= 4, + `the mark for ${rows} rows came out ${report.rail.deep.toFixed(3)} px deep` + ); + assert.ok(report.rail.top >= 0, "the mark hangs off the top of the rail"); + assert.ok( + report.rail.top + report.rail.deep <= 800 + 1e-6, + "the mark hangs off the bottom of the rail" + ); +}); + +check("the mark is drawn, in the colours it was given", () => { + const placed = balanced(12); + const { canvas } = drawnSet(placed, 800); + const filled = canvas.rects.filter((r) => r.kind === "fill"); + assert.strictEqual(filled.length, 1, "expected one filled mark"); + assert.strictEqual(filled[0].paint, theme.window, "the mark is not the window colour"); + assert.ok(canvas.rects.some((r) => r.kind === "stroke" && r.paint === theme.edge), "the mark has no edge"); +}); + +check("a click on the rail puts those rows on screen", () => { + const placed = balanced(16); + const { painter } = drawnSet(placed, 800); + // Zoomed in, so the mark is small enough to be placed rather than clamped. + for (let i = 0; i < 20; i++) painter.zoomAt(200, 400, 1.3); + const before = painter.looking().rows; + for (const py of [120, 400, 600, 750]) { + painter.jumpTo(0, py); + const report = painter.paint(theme); + const middle = report.rail.top + report.rail.deep / 2; + assert.ok( + Math.abs(middle - py) < 3, + `clicked at ${py} px and the mark came out centred at ${middle.toFixed(1)}` + ); + assert.ok( + Math.abs(painter.looking().rows - before) <= 2, + "the click changed how many rows are shown" + ); + } +}); + +check("and the check bites: the rail's two directions are inverses", () => { + const placed = balanced(16); + const { painter } = drawnSet(placed, 800); + for (let i = 0; i < 20; i++) painter.zoomAt(200, 400, 1.3); + // A scrub to the very top and the very bottom must not land in the same + // place, which is what a dropped or constant mapping would do. + painter.jumpTo(0, 0); + const top = painter.paint(theme).rail.top; + painter.jumpTo(0, 800); + const foot = painter.paint(theme).rail.top; + assert.ok(foot - top > 700, `the whole rail moved the mark only ${(foot - top).toFixed(1)} px`); +}); + +check("the rail knows what belongs to it", () => { + const placed = balanced(12); + const { painter, report } = drawnSet(placed, 800); + assert.ok(painter.onMap(report.rail.x0 + 2, 400), "a point on the rail was not claimed"); + assert.ok(painter.onMap(WIDE - 1, 400), "the far edge was not claimed"); + assert.ok(!painter.onMap(report.rail.x0 - 2, 400), "a point on the tree was claimed by the rail"); + assert.ok(!painter.onMap(10, 400), "the root end was claimed by the rail"); +}); + +check("a narrow canvas gets no rail, and all of its width", () => { + const placed = balanced(12); + const roomy = drawnSet(placed, 800, WIDE); + const tight = drawnSet(placed, 800, NARROW); + assert.ok(!tight.report.rail, "a phone width canvas was given a rail"); + assert.ok(!tight.painter.onMap(NARROW - 1, 400), "the rail claims points on a canvas that has none"); + assert.strictEqual(tight.canvas.rects.length, 0, "something was drawn where the rail would be"); + assert.ok(roomy.report.rail, "a wide canvas was refused a rail"); +}); + +// ----------------------------------------------------------------- the disc + +// The crate's own arithmetic, written out here from radial.rs rather than +// borrowed from the module under test, so the check is a second opinion and +// not an echo. A row of `terminals` goes to an angle starting at the top of +// the circle and going all the way round; a depth goes to a fraction of the +// way from a hole eight percent out to the rim. +function crateAngle(row, lowY, terminals) { + return -Math.PI / 2 + (Math.PI * 2 * (row - lowY)) / terminals; +} +function crateRadius(depth, lowX, highX) { + const span = highX - lowX; + const part = span > 0 ? (depth - lowX) / span : 0; + return 0.08 + Math.min(1, Math.max(0, part)) * 0.92; +} + +check("the disc puts a node where the crate would", () => { + const placed = balanced(10); // 1,024 tips + const canvas = fakeCanvas(WIDE, 800); + const painter = canvasModule.make(canvas); + painter.load(placed); + painter.shape(true); + painter.paint(theme); + + let lowY = Infinity, highY = -Infinity, lowX = Infinity, highX = -Infinity; + for (let i = 0; i < placed.count; i++) { + if (placed.y[i] < lowY) lowY = placed.y[i]; + if (placed.y[i] > highY) highY = placed.y[i]; + if (placed.x[i] < lowX) lowX = placed.x[i]; + if (placed.x[i] > highX) highX = placed.x[i]; + } + const terminals = highY - lowY + 1; + // At Fit the camera is the whole disc: half a canvas height over 1.1. + const scale = 800 / 2.2; + const midX = WIDE / 2, midY = 800 / 2; + + let worst = 0; + for (const node of [0, 1, 2, 17, 500, placed.count - 1]) { + const angle = crateAngle(placed.y[node], lowY, terminals); + const radius = crateRadius(placed.x[node], lowX, highX) * scale; + const want = { x: midX + Math.cos(angle) * radius, y: midY + Math.sin(angle) * radius }; + const got = painter.where(node); + worst = Math.max(worst, Math.abs(got.x - want.x), Math.abs(got.y - want.y)); + } + assert.ok(worst < 0.001, `the canvas and the crate disagree by ${worst.toFixed(4)} px`); +}); + +check("and the check bites: the disc is not the rectangle in disguise", () => { + const placed = balanced(10); + const canvas = fakeCanvas(WIDE, 800); + const painter = canvasModule.make(canvas); + painter.load(placed); + const flat = painter.where(3); + painter.shape(true); + painter.paint(theme); + const bent = painter.where(3); + assert.ok( + Math.abs(flat.x - bent.x) > 1 || Math.abs(flat.y - bent.y) > 1, + "switching projection moved nothing" + ); +}); + +check("switching projection asks the program for nothing", () => { + const placed = balanced(10); + const canvas = fakeCanvas(WIDE, 800); + const painter = canvasModule.make(canvas); + painter.load(placed); + const before = { x: placed.x.slice(), y: placed.y.slice(), parent: placed.parent.slice() }; + painter.shape(true); + painter.paint(theme); + painter.shape(false); + painter.paint(theme); + // The layout is the same rows and depths whichever way it is drawn, so + // nothing here may have touched them. + assert.deepStrictEqual(Array.from(placed.x), Array.from(before.x), "the depths moved"); + assert.deepStrictEqual(Array.from(placed.y), Array.from(before.y), "the rows moved"); + assert.deepStrictEqual(Array.from(placed.parent), Array.from(before.parent), "the tree moved"); +}); + +check("the walk gives up at the edge of the canvas", () => { + const placed = balanced(15); // 32,768 tips + const canvas = fakeCanvas(WIDE, 800); + const painter = canvasModule.make(canvas); + painter.load(placed); + painter.shape(true); + const whole = painter.paint(theme).drawn; + // In on the rim, where the middle of the disc that every walk heads for is + // off the canvas. + for (let i = 0; i < 12; i++) painter.zoomAt(WIDE / 2 + 200, 400, 1.35); + const rim = painter.paint(theme).drawn; + assert.ok(rim < whole, `zoomed in on the rim it drew ${rim}, more than the ${whole} of the whole disc`); + assert.ok(rim > 0, "zoomed in on the rim it drew nothing at all"); +}); + +check("and the check bites: without the edge the walk reaches the middle", () => { + const placed = balanced(15); + const canvas = fakeCanvas(WIDE, 800); + const painter = canvasModule.make(canvas); + painter.load(placed); + painter.shape(true); + painter.paint(theme); + for (let i = 0; i < 12; i++) painter.zoomAt(WIDE / 2 + 200, 400, 1.35); + painter.paint(theme); + // The root is what every walk is heading for, and at this zoom it is far + // outside the canvas: if the walk did not stop, it would be drawn. + const seen = painter.shown(); + let root = 0; + for (let i = 0; i < placed.count; i++) if (placed.parent[i] === 0xffffffff) root = i; + const at = painter.where(root); + assert.ok( + at.x < -48 || at.x > WIDE + 48 || at.y < -48 || at.y > 848, + "the root is on the canvas, so this check proves nothing" + ); + let drawnRoot = false; + for (let i = 0; i < seen.count; i++) if (seen.child[i] === root) drawnRoot = true; + assert.ok(!drawnRoot, "the root was drawn although it is off the canvas"); +}); + +check("the dial is the whole disc, and it does not follow the zoom", () => { + const placed = balanced(12); + const canvas = fakeCanvas(WIDE, 800); + const painter = canvasModule.make(canvas); + painter.load(placed); + painter.shape(true); + const wide = painter.paint(theme).rail; + assert.ok(wide, "no dial on a canvas with room for one"); + for (let i = 0; i < 20; i++) painter.zoomAt(WIDE / 2, 400, 1.3); + const close = painter.paint(theme).rail; + assert.strictEqual(close.drawn, wide.drawn, "the dial changed with the zoom"); + assert.ok(close.wide <= wide.wide, "the window on the dial grew as the view shrank"); + assert.ok(close.deep >= 4 && close.wide >= 4, "the window on the dial is too small to catch"); +}); + +check("a click on the dial goes to that part of the disc", () => { + const placed = balanced(12); + const canvas = fakeCanvas(WIDE, 800); + const painter = canvasModule.make(canvas); + painter.load(placed); + painter.shape(true); + painter.paint(theme); + for (let i = 0; i < 14; i++) painter.zoomAt(WIDE / 2, 400, 1.3); + const spot = painter.paint(theme).rail; + const midX = spot.x0 + spot.side / 2, midY = spot.y0 + spot.side / 2; + for (const [dx, dy] of [[-30, -30], [30, -30], [30, 30], [-30, 30]]) { + painter.jumpTo(midX + dx, midY + dy); + const after = painter.paint(theme).rail; + const seen = [after.left + after.wide / 2 - midX, after.top + after.deep / 2 - midY]; + assert.ok( + Math.abs(seen[0] - dx) < 4 && Math.abs(seen[1] - dy) < 4, + `clicked ${dx},${dy} from the middle and the window went to ${seen[0].toFixed(1)},${seen[1].toFixed(1)}` + ); + } +}); + +check("the dial knows what belongs to it, and a narrow canvas gets none", () => { + const placed = balanced(12); + const roomy = canvasModule.make(fakeCanvas(WIDE, 800)); + roomy.load(placed); + roomy.shape(true); + const spot = roomy.paint(theme).rail; + assert.ok(roomy.onMap(spot.x0 + 4, spot.y0 + 4), "a point on the dial was not claimed"); + assert.ok(!roomy.onMap(spot.x0 - 4, spot.y0 - 4), "a point on the disc was claimed by the dial"); + assert.ok(!roomy.onMap(WIDE / 2, 400), "the middle of the disc was claimed by the dial"); + + const tight = canvasModule.make(fakeCanvas(NARROW, 800)); + tight.load(placed); + tight.shape(true); + assert.ok(!tight.paint(theme).rail, "a phone width canvas was given a dial"); + assert.ok(!tight.onMap(NARROW - 4, 796), "the dial claims points on a canvas that has none"); +}); + process.exit(failures ? 1 : 0);