From caffbff7da92025ad9c0bd9b0ae8e38de4565e9b Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:56:04 +0200 Subject: [PATCH 1/4] Lift the choice of what to draw out of the paint Which nodes a run of rows comes down to, and the span in x that holds them, were written inline in the middle of painting them. Both are now named functions the paint calls, so a second drawing pass over the same tree cannot quietly pick a different set and disagree with the first about the shape of it. Nothing about the picture changes: the same 9,822 branches come out of a two million node tree in the same 1.7 ms. --- docs/assets/tree-canvas.js | 106 ++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/docs/assets/tree-canvas.js b/docs/assets/tree-canvas.js index d376742d..4df78a75 100644 --- a/docs/assets/tree-canvas.js +++ b/docs/assets/tree-canvas.js @@ -95,38 +95,28 @@ window.karyonCanvas = (function () { return low; } - function paint(theme) { - if (!view) return { drawn: 0, skipped: 0 }; - 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; }; + // ------------------------------------------------------------ selection - 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. + // The nodes to draw for a run of rows in `tall` pixels of height, as a run + // of indices into `into`. + // + // 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. The walks meet and stop, so + // it is a handful of extra nodes, and it is what puts a trunk on the + // picture. + // + // Everything drawn anywhere comes through here, which is what stops one + // part of the canvas disagreeing with another about the shape of the tree. + function select(from, to, tall, into) { + var stride = Math.max(1, Math.ceil((to - from) / Math.max(1, tall))); view.visit += 1; var visit = view.visit; var seen = view.seen; - var shown = view.shown; + var shown = into; var count = 0; var sampled = 0; for (var at = from; at < to; at += stride) { @@ -137,13 +127,51 @@ window.karyonCanvas = (function () { if (count === shown.length) { var wider = new Uint32Array(shown.length * 2); wider.set(shown); - shown = view.shown = wider; + shown = wider; } shown[count] = walk; count += 1; walk = view.parent[walk]; } } + return { nodes: shown, count: count, stride: stride, sampled: sampled }; + } + + // The span in x that holds a selection, which is the root on the left and + // the deepest node in it on the right. + function spread(nodes, count) { + var lowX = Infinity, highX = -Infinity; + for (var scan = 0; scan < count; scan++) { + var it = nodes[scan]; + if (view.x[it] < lowX) lowX = view.x[it]; + if (view.x[it] > highX) highX = view.x[it]; + } + 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 }; + 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 from = firstRow(camera.y0 - 1); + var to = firstRow(camera.y1 + 1); + var wanted = to - from; + var picked = select(from, to, box.tall, view.shown); + view.shown = picked.nodes; + var shown = picked.nodes; + 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,20 +180,10 @@ 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(shown, count); + 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; @@ -209,7 +227,7 @@ window.karyonCanvas = (function () { labels += 1; } } - return { drawn: drawn, skipped: wanted - sampled, labels: labels, stride: stride, rowsInView: wanted }; + return { drawn: drawn, skipped: wanted - picked.sampled, labels: labels, stride: stride, rowsInView: wanted }; } // --------------------------------------------------------------- camera From d61fad0e30a3fbcafb140619728d1d1183dd8806 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:12:17 +0200 Subject: [PATCH 2/4] Give the tree a rail that says where you are A wheel notch will take a reader to seven rows out of two million and nothing on the canvas said so, with Fit the only way back and it throwing away where they were. The rail down the right is the whole tree at the height of the canvas, drawn once from the same selection the main view is drawn from so it cannot disagree with it, and the block on it is the rows on screen: clicking, dragging or wheeling there goes to those rows and keeps how many of them there are. It holds its mark at four pixels because seven rows in two million is a thousandth of one, stands down on a canvas too narrow to spare the width, and is checked by eight new cases that fail when the rail is built from the window instead of the tree. --- docs/assets/tree-canvas.js | 158 ++++++++++++++++++++++++++++--- docs/assets/tree-viewer.js | 63 ++++++++++-- docs/stylesheets/tree-viewer.css | 5 + docs/tree.md | 4 +- tests/tree-canvas.test.js | 127 +++++++++++++++++++++++-- 5 files changed, 326 insertions(+), 31 deletions(-) diff --git a/docs/assets/tree-canvas.js b/docs/assets/tree-canvas.js index 4df78a75..dcfb7f8d 100644 --- a/docs/assets/tree-canvas.js +++ b/docs/assets/tree-canvas.js @@ -22,6 +22,16 @@ 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; + function make(canvas) { var view = null; var camera = null; @@ -84,6 +94,39 @@ 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, 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. + function overview(box) { + if (view.overviewFor === box.tall) return view.overview; + var picked = select(0, view.byRow.length, box.tall, new Uint32Array(1 << 13)); + view.overview = { nodes: picked.nodes, count: picked.count, reach: spread(picked.nodes, picked.count) }; + view.overviewFor = box.tall; + return view.overview; + } + // The first row at or after `value`, by binary search over the sorted rows. function firstRow(value) { var low = 0, high = view.byRow.length; @@ -157,9 +200,13 @@ window.karyonCanvas = (function () { 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 = box.wide / spanX; + 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; }; @@ -185,7 +232,7 @@ window.karyonCanvas = (function () { 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; @@ -227,7 +274,70 @@ window.karyonCanvas = (function () { labels += 1; } } - return { drawn: drawn, skipped: wanted - picked.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); + var inner = strip.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); }; + + // 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(); + + ctx.strokeStyle = theme.faint; + ctx.beginPath(); + for (var each = 0; each < seen.count; each++) { + var node = seen.nodes[each]; + var up = view.parent[node]; + if (up === 0xffffffff) continue; + var y = atY(view.y[node]); + var x1 = atX(view.x[node]); + var x0 = atX(view.x[up]); + ctx.moveTo(x0, y); + ctx.lineTo(x1, y); + ctx.moveTo(x0, y); + ctx.lineTo(x0, atY(view.y[up])); + } + ctx.stroke(); + + // 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 = atY(camera.y0); + var foot = atY(camera.y1); + 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 @@ -249,26 +359,44 @@ 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(); + var span = camera.y1 - camera.y0; + settle(camera.y0 - (dy / box.tall) * span, span); + } + + // Is this point, in canvas pixels, on the rail rather than on the tree? + function onRail(px) { + var strip = view ? rail(size()) : null; + 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 scrubTo(py) { + if (!view) return; + var box = size(); + 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; @@ -311,6 +439,8 @@ window.karyonCanvas = (function () { home: home, zoomAt: zoomAt, panBy: panBy, + onRail: onRail, + scrubTo: scrubTo, goTo: goTo, looking: looking, loaded: function () { return !!view; }, diff --git a/docs/assets/tree-viewer.js b/docs/assets/tree-viewer.js index 6eb69edd..25f780a8 100644 --- a/docs/assets/tree-viewer.js +++ b/docs/assets/tree-viewer.js @@ -106,6 +106,12 @@ 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 + // and its mark is the one thing on the canvas that is not a branch. + theme.frame = dark ? "#30363d" : "#d7dbe0"; + theme.faint = dark ? "#5b6672" : "#b3bac2"; + theme.window = dark ? "rgba(121, 192, 255, 0.22)" : "rgba(30, 100, 200, 0.16)"; + theme.edge = dark ? "#79c0ff" : "#1e64c8"; } // Painted on the spot rather than on a frame callback: a browser does not @@ -167,8 +173,15 @@ function hand() { var dragging = false; + var scrubbing = false; var from = { x: 0, y: 0 }; + // Where a pointer is, in the canvas's own pixels. + function at(event) { + var box = el.plot.getBoundingClientRect(); + return { x: event.clientX - box.left, y: event.clientY - box.top }; + } + el.plot.addEventListener( "wheel", function (event) { @@ -179,7 +192,13 @@ // 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 = { x: event.clientX - box.left, y: event.clientY - box.top }; + // 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.onRail(here.x)) painter.panBy(0, -step); + else painter.zoomAt(here.x, here.y, Math.exp(-step * 0.002)); repaint(); }, { passive: false } @@ -187,23 +206,46 @@ el.plot.addEventListener("pointerdown", function (event) { if (event.button !== 0 || !painter.loaded()) return; - dragging = true; + var here = at(event); + // 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.onRail(here.x)) { + scrubbing = true; + painter.scrubTo(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 (scrubbing) { + painter.scrubTo(at(event).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()) { + el.plot.classList.toggle("tv-onrail", painter.onRail(at(event).x)); + } }); ["pointerup", "pointercancel"].forEach(function (kind) { el.plot.addEventListener(kind, function (event) { - if (!dragging) return; + if (!dragging && !scrubbing) return; dragging = false; + scrubbing = false; el.plot.classList.remove("tv-dragging"); if (el.plot.hasPointerCapture(event.pointerId)) { el.plot.releasePointerCapture(event.pointerId); @@ -212,8 +254,9 @@ }); 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.onRail(here.x)) return; + painter.zoomAt(here.x, here.y, 2.4); repaint(); }); diff --git a/docs/stylesheets/tree-viewer.css b/docs/stylesheets/tree-viewer.css index d2580419..61ba10a3 100644 --- a/docs/stylesheets/tree-viewer.css +++ b/docs/stylesheets/tree-viewer.css @@ -121,6 +121,11 @@ .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. */ +.tv-plot.tv-onrail { cursor: ns-resize; } +.tv-plot.tv-onrail.tv-dragging { cursor: ns-resize; } + .tv-plot canvas { display: block; width: 100%; diff --git a/docs/tree.md b/docs/tree.md index 2b0e5bc9..5ab7be23 100644 --- a/docs/tree.md +++ b/docs/tree.md @@ -53,7 +53,9 @@ 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 rail down the right is the whole + tree at once: the block on it is the part you are looking at, and clicking or dragging there goes straight + to those rows. 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 760dce20..87d157d8 100644 --- a/tests/tree-canvas.test.js +++ b/tests/tree-canvas.test.js @@ -12,10 +12,13 @@ const assert = require("assert"); // draw so a test can ask what is on it. function fakeCanvas(wide, tall) { const strokes = []; + const rects = []; const ctx = { setTransform() {}, clearRect() {}, beginPath() {}, stroke() {}, - moveTo(x, y) { strokes.push(["move", x, y]); }, - lineTo(x, y) { strokes.push(["line", x, y]); }, + 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 }); }, + strokeRect(x, y, w, h) { rects.push({ kind: "stroke", x, y, w, h, paint: ctx.strokeStyle }); }, fillText() {}, strokeStyle: "", fillStyle: "", lineWidth: 1, font: "", textBaseline: "", }; @@ -23,6 +26,7 @@ function fakeCanvas(wide, tall) { width: wide, height: tall, clientWidth: wide, clientHeight: tall, getContext: () => ctx, strokes, + rects, }; } @@ -75,19 +79,26 @@ 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", +}; + +// 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 @@ -169,4 +180,108 @@ check("the depth window holds the root and the deepest tip on screen", () => { assert.ok(window.x1 >= high - 1e-6, "the deepest tip on screen is off the right edge"); }); +// ----------------------------------------------------------------- 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.scrubTo(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.scrubTo(0); + const top = painter.paint(theme).rail.top; + painter.scrubTo(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.onRail(report.rail.x0 + 2), "a point on the rail was not claimed"); + assert.ok(painter.onRail(WIDE - 1), "the far edge was not claimed"); + assert.ok(!painter.onRail(report.rail.x0 - 2), "a point on the tree was claimed by the rail"); + assert.ok(!painter.onRail(10), "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.onRail(NARROW - 1), "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"); +}); + process.exit(failures ? 1 : 0); From f3703045cec257f6a6203c4efcdda849cbd59abe Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:37:48 +0200 Subject: [PATCH 3/4] Bound the walk to the root, and fix what a review found on the rail A review of the rail found five things and measurement backed all of them. The walk up to the root has no bound on a ladder, where every tip hangs off one spine: a hundred and twenty thousand tip caterpillar cost a hundred and fifty eight milliseconds a frame and stayed expensive when zoomed into, so the walk now steps over an ancestor that would be drawn inside the same pixel as the branch below it and that tree costs nine. Compared against the old selection pixel by pixel the ink does not move: at a deep zoom the two draw the same number of branches and every differing pixel is one of the tip names that used to run under the rail. The rail is drawn once onto a canvas of its own and stamped from there rather than re-stroked, which took a frame with fifty branches on it from ten milliseconds to none. Clicks measured the box around the canvas rather than the canvas, and that box has a one pixel border, so every one of them landed three thousand rows low at two million tips. The rail's ink was 1.96 to 1 on white, which is a silhouette nobody can see, and its mark was a fourth hue on a three hue page: they are now 3.75 to 1 and the page's own accent. A gesture belongs to the pointer that began it and ends on lost capture as well, and a canvas with no size in the stylesheet can no longer grow without limit. --- docs/assets/tree-canvas.js | 256 +++++++++++++++++++++++++++---------- docs/assets/tree-viewer.js | 45 +++++-- tests/tree-canvas.test.js | 95 ++++++++++++-- 3 files changed, 304 insertions(+), 92 deletions(-) diff --git a/docs/assets/tree-canvas.js b/docs/assets/tree-canvas.js index dcfb7f8d..3c8322cc 100644 --- a/docs/assets/tree-canvas.js +++ b/docs/assets/tree-canvas.js @@ -70,9 +70,15 @@ 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(); } @@ -85,8 +91,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); @@ -116,15 +129,76 @@ window.karyonCanvas = (function () { } // The whole tree, thinned to the rail's own height. Built once, and again - // only if the canvas changes height, 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. - function overview(box) { - if (view.overviewFor === box.tall) return view.overview; - var picked = select(0, view.byRow.length, box.tall, new Uint32Array(1 << 13)); - view.overview = { nodes: picked.nodes, count: picked.count, reach: spread(picked.nodes, picked.count) }; + // 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; - return view.overview; + 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. @@ -140,54 +214,107 @@ window.karyonCanvas = (function () { // ------------------------------------------------------------ selection - // The nodes to draw for a run of rows in `tall` pixels of height, as a run - // of indices into `into`. + // 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. The walks meet and stop, so - // it is a handful of extra nodes, and it is what puts a trunk on the - // picture. + // 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. - function select(from, to, tall, into) { + function select(from, to, wide, tall, spanY, store) { var stride = Math.max(1, Math.ceil((to - from) / Math.max(1, tall))); view.visit += 1; var visit = view.visit; var seen = view.seen; - var shown = into; + 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; + + // 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 look = from; look < to; 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 at = from; at < to; 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 = 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; } - shown[count] = walk; + child[count] = walk; + up[count] = over; count += 1; - walk = view.parent[walk]; + walk = over; } } - return { nodes: shown, count: count, stride: stride, sampled: sampled }; + 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 node in it on the right. - function spread(nodes, count) { + // 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 < count; scan++) { - var it = nodes[scan]; - if (view.x[it] < lowX) lowX = view.x[it]; - if (view.x[it] > highX) highX = view.x[it]; + 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 }; @@ -214,9 +341,8 @@ window.karyonCanvas = (function () { var from = firstRow(camera.y0 - 1); var to = firstRow(camera.y1 + 1); var wanted = to - from; - var picked = select(from, to, box.tall, view.shown); - view.shown = picked.nodes; - var shown = picked.nodes; + 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; @@ -227,7 +353,7 @@ 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 reach = spread(shown, count); + var reach = spread(picked); var margin = (reach.highX - reach.lowX) * 0.28; camera.x0 = reach.lowX - margin * 0.05; camera.x1 = reach.highX + margin; @@ -241,18 +367,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(); @@ -265,12 +389,17 @@ 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; } } @@ -290,14 +419,7 @@ window.karyonCanvas = (function () { // 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); - var inner = strip.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); }; + var seen = overview(box, theme); // The edge it stands behind, so the rail reads as a margin and not as // more tree. @@ -308,27 +430,14 @@ window.karyonCanvas = (function () { ctx.lineTo(strip.x0 + 0.5, box.tall); ctx.stroke(); - ctx.strokeStyle = theme.faint; - ctx.beginPath(); - for (var each = 0; each < seen.count; each++) { - var node = seen.nodes[each]; - var up = view.parent[node]; - if (up === 0xffffffff) continue; - var y = atY(view.y[node]); - var x1 = atX(view.x[node]); - var x0 = atX(view.x[up]); - ctx.moveTo(x0, y); - ctx.lineTo(x1, y); - ctx.moveTo(x0, y); - ctx.lineTo(x0, atY(view.y[up])); - } - 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 = atY(camera.y0); - var foot = atY(camera.y1); + 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; @@ -448,7 +557,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 25f780a8..b3816531 100644 --- a/docs/assets/tree-viewer.js +++ b/docs/assets/tree-viewer.js @@ -106,12 +106,17 @@ 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 - // and its mark is the one thing on the canvas that is not a branch. + // 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 ? "#5b6672" : "#b3bac2"; - theme.window = dark ? "rgba(121, 192, 255, 0.22)" : "rgba(30, 100, 200, 0.16)"; - theme.edge = dark ? "#79c0ff" : "#1e64c8"; + 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"; } // Painted on the spot rather than on a frame callback: a browser does not @@ -174,11 +179,17 @@ 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. + // 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.plot.getBoundingClientRect(); + var box = el.canvas.getBoundingClientRect(); return { x: event.clientX - box.left, y: event.clientY - box.top }; } @@ -187,12 +198,11 @@ 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; - var here = { x: event.clientX - box.left, y: event.clientY - box.top }; + var here = at(event); // 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 @@ -205,8 +215,9 @@ ); el.plot.addEventListener("pointerdown", function (event) { - if (event.button !== 0 || !painter.loaded()) return; + 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. @@ -223,6 +234,7 @@ }); el.plot.addEventListener("pointermove", function (event) { + if (owner !== null && event.pointerId !== owner) return; if (scrubbing) { painter.scrubTo(at(event).y); repaint(); @@ -241,11 +253,19 @@ } }); - ["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 && !scrubbing) 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); @@ -255,6 +275,7 @@ el.plot.addEventListener("dblclick", function (event) { var here = at(event); + if (!painter.loaded()) return; if (painter.onRail(here.x)) return; painter.zoomAt(here.x, here.y, 2.4); repaint(); diff --git a/tests/tree-canvas.test.js b/tests/tree-canvas.test.js index 87d157d8..2ef5afb6 100644 --- a/tests/tree-canvas.test.js +++ b/tests/tree-canvas.test.js @@ -13,13 +13,15 @@ const assert = require("assert"); function fakeCanvas(wide, tall) { const strokes = []; const rects = []; + const texts = []; const ctx = { setTransform() {}, clearRect() {}, beginPath() {}, stroke() {}, 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 }); }, strokeRect(x, y, w, h) { rects.push({ kind: "stroke", x, y, w, h, paint: ctx.strokeStyle }); }, - fillText() {}, + fillText(t, x, y) { texts.push({ t, x, y }); }, + measureText(t) { return { width: t.length * 6 }; }, strokeStyle: "", fillStyle: "", lineWidth: 1, font: "", textBaseline: "", }; return { @@ -27,6 +29,7 @@ function fakeCanvas(wide, tall) { getContext: () => ctx, strokes, rects, + texts, }; } @@ -134,17 +137,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.length} branches hang from nothing at ${tall} px`); - assert.ok(on.has(0), `the root is not drawn at ${tall} px`); + 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.ok(root, `nothing reaches the root at ${tall} px`); } }); @@ -171,15 +181,76 @@ 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", () => { From ca9e5752d172ca259b3a43a76b194ff9ab131ee9 Mon Sep 17 00:00:00 2001 From: Paula Ruiz Rodriguez <50167687+Paururo@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:21:21 +0200 Subject: [PATCH 4/4] Bend the viewer round into a circle, and give the disc its own way home The page had one projection where the crate has three, and the rail only fits the one it had: a circle has no top and bottom to scroll between. So the circular view draws the same layout in the crate's own polar arithmetic, checked against the formula in radial.rs rather than against itself, and the small picture of the whole tree becomes a dial in the corner with the window drawn on it. Nothing is asked of the program to switch: the rows and depths are the same numbers, read as angles and radii. A million tips comes out at fourteen milliseconds a frame at full extent and under two anywhere in a zoom, which took two bounds. A walk that has gone further in than the window can see has nothing left to draw, and a branch further out than the window can see is walked past rather than drawn: the second one takes twenty four thousand branches down to two on the worst gesture and, compared pixel by pixel against the same view without it, does not change the picture at all. --- docs/assets/tree-canvas.js | 492 +++++++++++++++++++++++++++++-- docs/assets/tree-viewer.js | 40 ++- docs/stylesheets/tree-viewer.css | 8 +- docs/tree.md | 9 +- tests/tree-canvas.test.js | 192 +++++++++++- 5 files changed, 701 insertions(+), 40 deletions(-) diff --git a/docs/assets/tree-canvas.js b/docs/assets/tree-canvas.js index 3c8322cc..df6f2df1 100644 --- a/docs/assets/tree-canvas.js +++ b/docs/assets/tree-canvas.js @@ -32,9 +32,17 @@ window.karyonCanvas = (function () { // 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) { @@ -83,6 +91,7 @@ window.karyonCanvas = (function () { } 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. @@ -142,7 +151,7 @@ window.karyonCanvas = (function () { if (view.overviewFor === box.tall && view.overviewInk === theme.faint) { return view.overview; } - var picked = select(0, view.byRow.length, RAIL_WIDE, box.tall, + 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), @@ -212,6 +221,339 @@ window.karyonCanvas = (function () { return low; } + // 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 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); + + // 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 @@ -236,8 +578,13 @@ window.karyonCanvas = (function () { // // Everything drawn anywhere comes through here, which is what stops one // part of the canvas disagreeing with another about the shape of the tree. - function select(from, to, wide, tall, spanY, store) { - var stride = Math.max(1, Math.ceil((to - from) / Math.max(1, tall))); + // `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; @@ -255,9 +602,11 @@ window.karyonCanvas = (function () { // the deepest of the rows sampled. Neither needs the walk to have run. var lowX = x[view.root]; var highX = lowX; - for (var look = from; look < to; look += stride) { - var seenX = x[view.byRow[look]]; - if (seenX > highX) highX = seenX; + 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 @@ -270,7 +619,8 @@ window.karyonCanvas = (function () { var columnOf = function (node) { return ((x[node] - lowX) * acrossX) | 0; }; var rowOf = function (node) { return (y[node] * downY) | 0; }; - for (var at = from; at < to; at += stride) { + 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) { @@ -294,12 +644,22 @@ window.karyonCanvas = (function () { wideUp.set(up); up = wideUp; } - child[count] = walk; - up[count] = over; - count += 1; + // 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; + } + // 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 }; } @@ -322,6 +682,7 @@ window.karyonCanvas = (function () { 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); @@ -341,7 +702,7 @@ window.karyonCanvas = (function () { 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); + 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; @@ -454,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; @@ -486,22 +863,58 @@ window.karyonCanvas = (function () { // 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); } - // Is this point, in canvas pixels, on the rail rather than on the tree? - function onRail(px) { - var strip = view ? rail(size()) : null; + // 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 scrubTo(py) { + 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); } @@ -524,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: @@ -548,8 +971,39 @@ window.karyonCanvas = (function () { home: home, zoomAt: zoomAt, panBy: panBy, - onRail: onRail, - scrubTo: scrubTo, + 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; }, diff --git a/docs/assets/tree-viewer.js b/docs/assets/tree-viewer.js index b3816531..3146046d 100644 --- a/docs/assets/tree-viewer.js +++ b/docs/assets/tree-viewer.js @@ -117,6 +117,9 @@ // 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 @@ -143,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]); } @@ -203,12 +207,18 @@ // useless or violent next to a trackpad. var step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; 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.onRail(here.x)) painter.panBy(0, -step); - else painter.zoomAt(here.x, here.y, Math.exp(-step * 0.002)); + 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 } @@ -221,9 +231,9 @@ // 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.onRail(here.x)) { + if (painter.onMap(here.x, here.y)) { scrubbing = true; - painter.scrubTo(here.y); + painter.jumpTo(here.x, here.y); repaint(); } else { dragging = true; @@ -236,7 +246,8 @@ el.plot.addEventListener("pointermove", function (event) { if (owner !== null && event.pointerId !== owner) return; if (scrubbing) { - painter.scrubTo(at(event).y); + var where = at(event); + painter.jumpTo(where.x, where.y); repaint(); return; } @@ -249,7 +260,10 @@ // Not a gesture, just a hand passing over. The cursor says which of the // two things underneath it would answer. if (painter.loaded()) { - el.plot.classList.toggle("tv-onrail", painter.onRail(at(event).x)); + 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()); } }); @@ -276,7 +290,7 @@ el.plot.addEventListener("dblclick", function (event) { var here = at(event); if (!painter.loaded()) return; - if (painter.onRail(here.x)) return; + if (painter.onMap(here.x, here.y)) return; painter.zoomAt(here.x, here.y, 2.4); repaint(); }); @@ -365,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; @@ -386,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 61ba10a3..9174020a 100644 --- a/docs/stylesheets/tree-viewer.css +++ b/docs/stylesheets/tree-viewer.css @@ -122,9 +122,13 @@ .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. */ -.tv-plot.tv-onrail { cursor: ns-resize; } + 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; diff --git a/docs/tree.md b/docs/tree.md index 5ab7be23..c963f57d 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 rail down the right is the whole - tree at once: the block on it is the part you are looking at, and clicking or dragging there goes straight - to those rows. 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 2ef5afb6..0d2d6057 100644 --- a/tests/tree-canvas.test.js +++ b/tests/tree-canvas.test.js @@ -14,11 +14,14 @@ 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, 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 }; }, @@ -30,6 +33,7 @@ function fakeCanvas(wide, tall) { strokes, rects, texts, + arcs, }; } @@ -85,6 +89,7 @@ function balanced(levels) { 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. @@ -309,7 +314,7 @@ check("a click on the rail puts those rows on screen", () => { 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.scrubTo(py); + painter.jumpTo(0, py); const report = painter.paint(theme); const middle = report.rail.top + report.rail.deep / 2; assert.ok( @@ -329,9 +334,9 @@ check("and the check bites: the rail's two directions are inverses", () => { 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.scrubTo(0); + painter.jumpTo(0, 0); const top = painter.paint(theme).rail.top; - painter.scrubTo(800); + 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`); }); @@ -339,10 +344,10 @@ check("and the check bites: the rail's two directions are inverses", () => { check("the rail knows what belongs to it", () => { const placed = balanced(12); const { painter, report } = drawnSet(placed, 800); - assert.ok(painter.onRail(report.rail.x0 + 2), "a point on the rail was not claimed"); - assert.ok(painter.onRail(WIDE - 1), "the far edge was not claimed"); - assert.ok(!painter.onRail(report.rail.x0 - 2), "a point on the tree was claimed by the rail"); - assert.ok(!painter.onRail(10), "the root end was claimed by the rail"); + 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", () => { @@ -350,9 +355,180 @@ check("a narrow canvas gets no rail, and all of its width", () => { 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.onRail(NARROW - 1), "the rail claims points on a canvas that has none"); + 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);