diff --git a/Client/AccessGate/access-gate.js b/Client/AccessGate/access-gate.js new file mode 100644 index 0000000..127dae9 --- /dev/null +++ b/Client/AccessGate/access-gate.js @@ -0,0 +1,97 @@ +;(function () { + "use strict" + + /** + * AccessGate client: solves the proof-of-work challenge issued by + * Service/access-gate-seed-service.php and stores the resulting token + * in the `cv_gate` cookie (see Module/AccessGate.php for the protocol). + * + * Used by Client/AccessGate/challenge.html (full-page challenge) and by + * XHR/fetch callers (e.g. ContentsViewer feedback) via ensureToken(). + */ + var AG = {} + + AG.COOKIE_NAME = "cv_gate" + + AG.isSupported = function () { + return !!(window.crypto && window.crypto.subtle && window.TextEncoder) + } + + AG.hasToken = function () { + return document.cookie.indexOf(AG.COOKIE_NAME + "=") !== -1 + } + + /** + * Finds a nonce such that sha256(seed + "." + nonce) has `bits` leading + * zero bits. Resolves with the nonce (number). onProgress(attempts) is + * called periodically. + */ + AG.solve = async function (seed, bits, onProgress) { + var encoder = new TextEncoder() + var fullBytes = bits >> 3 + var restBits = bits & 7 + for (var nonce = 0; ; nonce++) { + var data = encoder.encode(seed + "." + nonce) + var digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data)) + var ok = true + for (var i = 0; i < fullBytes; i++) { + if (digest[i] !== 0) { ok = false; break } + } + if (ok && restBits > 0 && (digest[fullBytes] >> (8 - restBits)) !== 0) { + ok = false + } + if (ok) return nonce + if (onProgress && (nonce & 255) === 0) onProgress(nonce) + } + } + + AG.setCookie = function (token) { + // seed = v1.{expiry}.{bits}.{mac}; cookie lifetime follows the token expiry. + var expiry = parseInt(token.split(".")[1], 10) + var maxAge = Math.max(60, expiry - Math.floor(Date.now() / 1000)) + document.cookie = AG.COOKIE_NAME + "=" + token + + "; path=/; max-age=" + maxAge + "; SameSite=Lax" + + (location.protocol === "https:" ? "; Secure" : "") + return AG.hasToken() + } + + /** + * Fetches a seed from `serviceUri` (e.g. CV.vars.serviceUri), solves the + * proof-of-work and stores the cookie. + * Resolves with {ok: true} or {ok: false, reason: string}. + */ + AG.acquireToken = async function (serviceUri, onProgress) { + if (!AG.isSupported()) return { ok: false, reason: "unsupported" } + if (!navigator.cookieEnabled) return { ok: false, reason: "cookie-disabled" } + var response + try { + response = await fetch(serviceUri + "/access-gate-seed-service.php", { + method: "POST", + cache: "no-store", + }) + } catch (error) { + return { ok: false, reason: "network" } + } + if (!response.ok) return { ok: false, reason: "seed-failed" } + var body + try { + body = await response.json() + } catch (error) { + return { ok: false, reason: "seed-failed" } + } + if (!body || typeof body.seed !== "string") return { ok: false, reason: "seed-failed" } + var nonce = await AG.solve(body.seed, body.bits, onProgress) + if (!AG.setCookie(body.seed + "." + nonce)) { + return { ok: false, reason: "cookie-disabled" } + } + return { ok: true } + } + + /** acquireToken() unless a token cookie is already present. */ + AG.ensureToken = async function (serviceUri, onProgress) { + if (AG.hasToken()) return { ok: true } + return AG.acquireToken(serviceUri, onProgress) + } + + window.AccessGate = AG +})() diff --git a/Client/AccessGate/challenge.html b/Client/AccessGate/challenge.html new file mode 100644 index 0000000..37b6454 --- /dev/null +++ b/Client/AccessGate/challenge.html @@ -0,0 +1,121 @@ + + + + + + +Checking your browser… + + + + +

ページを確認しています… / Verifying your browser…

+

+ + + + diff --git a/Client/ContentsViewer/ContentsViewer.js b/Client/ContentsViewer/ContentsViewer.js index d306dc2..621a061 100644 --- a/Client/ContentsViewer/ContentsViewer.js +++ b/Client/ContentsViewer/ContentsViewer.js @@ -710,30 +710,50 @@ CV.elements.searchResults.appendChild(div) } + // Sends a FormData to feedback-service with an AccessGate token + // (see Client/AccessGate/access-gate.js), acquiring one first when + // missing (proof-of-work, about a second) and retrying once on 428. + CV.sendFeedbackForm = function (form) { + var send = function (isRetry) { + var xhr = new XMLHttpRequest() + xhr.open("POST", CV.vars.serviceUri + "/feedback-service.php", true) + xhr.onload = function (e) { + if (this.status == 428 && !isRetry && window.AccessGate) { + AccessGate.acquireToken(CV.vars.serviceUri).then(function (result) { + if (result.ok) send(true) + else console.error("AccessGate: " + result.reason) + }) + return + } + try { + if (!CV.validateResponse(this)) { + throw "Sorry... Internal Error occured." + } + + if (this.parsedResponse.error) { + throw this.parsedResponse.error + } + } catch (err) { + console.error(err) + return + } + } + xhr.send(form) + } + + if (window.AccessGate) { + AccessGate.ensureToken(CV.vars.serviceUri).then(function () { send(false) }) + } else { + send(false) + } + } + CV.sendRating = function (button) { var rating = button.getAttribute("data-value") var form = new FormData() form.append("cmd", "rate") form.append("contentPath", CV.vars.contentPath) form.append("rating", rating) - form.append("otp", CV.vars.otp) - - var xhr = new XMLHttpRequest() - xhr.open("POST", CV.vars.serviceUri + "/feedback-service.php", true) - xhr.onload = function (e) { - try { - if (!CV.validateResponse(this)) { - throw "Sorry... Internal Error occured." - } - - if (this.parsedResponse.error) { - throw this.parsedResponse.error - } - } catch (err) { - console.error(err) - return - } - } var survey = document.getElementById("content-survey") document.querySelector("#content-survey .button-group").style.display = "none" @@ -759,7 +779,7 @@ } button.classList.add("submit-button") survey.appendChild(button) - xhr.send(form) + CV.sendFeedbackForm(form) } CV.sendMessage = function () { @@ -768,31 +788,13 @@ form.append("cmd", "message") form.append("contentPath", CV.vars.contentPath) form.append("message", message) - form.append("otp", CV.vars.otp) - - var xhr = new XMLHttpRequest() - xhr.open("POST", CV.vars.serviceUri + "/feedback-service.php", true) - xhr.onload = function (e) { - try { - if (!CV.validateResponse(this)) { - throw "Sorry... Internal Error occured." - } - - if (this.parsedResponse.error) { - throw this.parsedResponse.error - } - } catch (err) { - console.error(err) - return - } - } var survey = document.getElementById("content-survey") survey.classList.add("submitted") document.querySelector("#content-survey .how-improve").style.display = "none" document.querySelector("#content-survey .any-feedback").style.display = "none" - xhr.send(form) + CV.sendFeedbackForm(form) } CV.onClickLayerSelector = function (element, event) { @@ -854,7 +856,6 @@ CV.vars.token = (item = document.getElementsByName("token").item(0)) ? item.content : undefined CV.vars.contentPath = (item = document.getElementsByName("content-path").item(0)) ? item.content : undefined CV.vars.serviceUri = (item = document.getElementsByName("service-uri").item(0)) ? item.content : undefined - CV.vars.otp = (item = document.getElementsByName("otp").item(0)) ? item.content : undefined CV.elements = {} CV.elements.header = document.querySelector("#header") diff --git a/Client/TagMap/TagMap.js b/Client/TagMap/TagMap.js new file mode 100644 index 0000000..2b70497 --- /dev/null +++ b/Client/TagMap/TagMap.js @@ -0,0 +1,5500 @@ +;(function () { + "use strict" + + /** + * TagMap: nested circles you travel into. + * + * The selection path IS the nesting. `/Library/C#` means the root field + * contains Library's circle, which contains C#'s circle, and a child is + * always a slot inside its parent (see tagmap-layout.js for the geometry + * and its tests). So the circle you click is the circle you enter, and + * entering it moves only the CAMERA: the re-anchor is an algebraic + * identity, which is why the entered circle stays exactly where it was + * and the siblings you leave behind do not move at all. + * + * Coordinates are a presentation concern and exist only here. The server + * returns data -- tags with counts, one page of contents -- and knows + * nothing about positions, so the same path draws the same picture for + * everyone. State lives as: + * URL the selection (canonical, shareable) + * memory payload cache (current level and its ancestors), camera + * history.state nothing (popstate reads the URL and the memory cache) + * + * Reading it: + * amber boundary the level you are in + * circles inside its child tags, sized by the count on their label + * dots in the band contents sitting directly here, in none of the + * children; hollow means matched by tag-name similarity + * dashed circle a similar tag NAME, which may share no content + * faint outlines the levels you came through and their other children + * + * Every number names the payload field it came from, and none is + * substituted when a field is missing: a tag with no `count` is not a tag + * whose count is its global total. + * + * Interaction: tap a child to enter it, drag one child onto another to + * enter both together (a NEW level, not a wider version of this one), + * drag a sibling INTO the boundary to absorb it into this segment, drag + * out past the boundary to leave. Drag empty space to pan, wheel/pinch + * to zoom -- zoom is mostly level-of-detail, but pushing past a clamp or + * filling the viewport with one group does navigate; see + * considerZoomTransition. + * NOTE: never use location.hash (ContentsViewer.js owns hashchange). + */ + + var TM = {} + + // ---- config ----------------------------------------------------------- + + // Mirrors of TAGMAP_MAX_DEPTH / TAGMAP_MAX_WIDTH, read from the page in + // init() so the two cannot drift; these are only the fallback defaults. + var MAX_DEPTH = 5 + var MAX_WIDTH = 5 + var PAGE_SIZE = 20 + var TAU = Math.PI * 2 + + // World units per current-level radius. Any value works (the camera + // fits to it); this one keeps hairlines and label sizes in a range the + // canvas is comfortable with. + // The world unit is one content radius (Layout.CONTENT_R), so there is + // no per-level normalisation constant any more: a level's radius comes + // from its own content count and lives in state.levelR. + // + // The zoom ceiling lives in the layout module now (Layout.zoomCeiling), + // because it is derived: it is the scale at which a content's CARD holds + // a full excerpt. The old ceiling of 28 px was the right rule for a DISC + // -- text does not scale with the camera, so magnifying a circle buys + // nothing -- and the wrong rule for a card, whose whole point is text. + var FIT_FILL = 0.94 // of the viewport, when a level is framed + // How far past the fit the ROOT may be pulled back. The root has no + // parent to frame, so this is a comfort figure rather than a derived one. + var ROOT_PULLBACK = 2.6 + var ANCESTORS_DRAWN = 2 // deeper ancestors are culled, not composed + var GHOST_RING_PAD = 1.06 // just outside the level, in level radii + var GUIDE_RINGS = [0.25, 0.5, 0.75] + var RING_HIT = 14 // screen px around an ancestor boundary + + // Outlines that get a fill. Every group is stroked, but filling 106 + // overlapping translucent shapes both costs fill rate and turns the level + // into mud -- the overlaps read as darker regions that mean nothing. The + // biggest few carry the sense of mass; the rest are outlines. + var NEBULA_FILLS = 12 + var NEBULA_FILL_ALPHA = 0.10 + var NEBULA_LINE_ALPHA = 0.55 + // Screen radius below which a group's outline is not drawn at all. + // + // Not a performance measure -- the whole renderer costs about 0.6 ms a + // frame. It is legibility: an outline is a CLAIM about which contents + // belong together, and 40 of the root's 106 groups hold a single content, + // so their outlines are 40 small circles crossing everything and saying + // nothing you cannot already see from the mark inside them. Their + // contents are still drawn; only the claim waits until it can be read. + var NEBULA_MIN_SCREEN_R = 26 + // Content screen radius at which a mark stops being a dot in a batched + // path and becomes an object with its own styling. It is also where the + // title tier begins (W6): a 22 px disc is about the point at which a + // label beside it stops swamping it. + // Frames kept for the percentile readout. + var PERF_WINDOW = 120 + var perf = { frames: 0, total: 0, worst: 0, recent: [] } + // Past this many contents inside the child groups, showing them all in + // place is noise rather than help (the former view used the same rule). + var STAR_R = 7 + + var LABEL_MIN_SCREEN_R = 16 + var LABEL_MAX_BOXES = 30 // labels per frame, collision-checked + var MAX_DRAW_ERRORS = 90 // consecutive failing frames before giving up + // Level-of-detail thresholds, in CSS px of CONTENT RADIUS. Absolute, so + // they stop being ratios against a fit that changed every level. + var STAR_MIN_PX = 1.5 // below this the outlines carry the density + var RING_PX = 5 // where a dot stops being drawn in a batch + // How solid a mark is. It rises with the mark rather than the mark + // rising with it: the SIZE states the content's real extent and must not + // be shrunk to quieten it. At the far end 411 marks would otherwise read + // as the subject and the nebulae as background, which is the metaphor + // upside down. Above RING_PX it is simply 1. + var STAR_ALPHA_MIN = 0.35 + var STAR_ALPHA_MAX = 1.0 + // The background speck field. Sized so an ink dot survives on paper -- + // see buildStarLayer -- and kept clear of the marks by weight rather + // than by size, since the two overlap in size at the far end anyway (a + // mark is about 4 px there). + var STAR_COUNT = 200 + var STAR_MIN_R = 0.6 + var STAR_MAX_R = 2.2 + // How far ahead of the card's own thresholds a body is fetched. What a + // card can hold is Layout.cardFor()'s answer, not a separate list of px + // -- but asking exactly when the text would appear shows a blank card + // for one round trip, so the request runs one zoom step early. A wheel + // step is 1.302, measured; 1.4 covers it with room over. + var BODY_LEAD = 1.4 + // Bodies asked for in one request. Bounded by what a viewport can show, + // not by the manifest: each costs the server about 17 ms the first time + // its file is read. + var BODY_BATCH = 24 + // Hit target, whatever the mark's size. A 2 px dot still has to be + // tappable, so the target is decoupled from the level of detail. + var HIT_MIN_PX = 22 + + var TAP_MOVE_PX = 8 + var TAP_MS = 400 + var DRAG_START_PX = 10 + var EASE = 0.14 + var CONTAINER_FILL = 0.72 // share of the viewport the current level fills + + var Layout = window.TagMapLayout + + // ---- state (all of it in memory) -------------------------------------- + + var state = { + serviceUri: null, + contentPath: null, + csrfToken: null, + layer: "ja", + segments: [], + data: null, + payloads: {}, // tagPath -> response memory cache, incl. ancestors + // Derived from payloads by buildScene(); never mutated elsewhere, so + // the same payloads always give the same picture. + layout: { slots: [], byTag: {} }, // children of the current level + universe: {}, // tag -> {tag, kind, x, y, r} in current-level space + members: [], // [{tag, count, total}] the current level's children + slotChain: [], // slot each level occupies inside its parent + levels: [], // transforms: current level first, then ancestors + ancestors: [], // [{depth, x, y, r, children: [...]}] + band: [], // direct-content dot positions, index-stable + nebula: null, // {points, contours} -- the contents-first scene + groupRemap: {}, // manifest group index -> slot index (see buildScene) + stars: [], // [{item, ...}] paired with band positions + trail: [], // [{tagPath, label, x, y}] + } + + var camera = { x: 0, y: 0, scale: 1 } + var cameraTarget = null + var popupTarget = null + // Which content the detail card is showing: {key, item}. The card used + // to keep no state at all -- "is it open" was read off a DOM class -- so + // there was nothing to draw a connector from. positionPopup already + // re-anchors the tag popup to its node every frame; this is the same + // idea for the lines. + var infoTarget = null + var drag = null // {tag, x, y, target} + var dragHint = null // screen-space label for the current drop target + var running = false + // Ticks once per frame; the nebula path cache keys on it. + var frameSerial = 0 + var drawErrors = 0 // consecutive failing frames; see frame() + // Read live, not once. Until now this gated one thing -- the ease that + // frames a level -- and every motion in the canvas was transient, so a + // stale answer cost at most one eased camera move. The decorations below + // never stop, so a reader who turns the preference on mid-session has to + // be obeyed mid-session. + var motionQuery = window.matchMedia + ? window.matchMedia("(prefers-reduced-motion: reduce)") : null + var reducedMotion = motionQuery ? motionQuery.matches : false + if (motionQuery && motionQuery.addEventListener) { + motionQuery.addEventListener("change", function (event) { + reducedMotion = event.matches + }) + } + + var elements = {} + var ctx = null + var dpr = 1 + var cssW = 0 + var cssH = 0 + var starLayer = null + var controllers = { nav: null, page: null } + var ancestorFetches = {} // cacheKey -> in flight, so we ask only once + // Content bodies, by manifest key. The map draws every content of a level + // from the manifest, which carries identities and no bodies, so a mark + // that grows big enough for a title has to ask for one. Kept across + // navigations: a content keeps its key, so climbing back out costs + // nothing. Never evicted -- the whole corpus of titles is a few KB. + // + // Most of a body is selection-independent -- a title, a summary and a URL + // are the same wherever the content is drawn -- but `suggested` and + // `score` are NOT: they say "in the selection you asked about, this + // matched by name rather than by a tag". So each cached item records the + // selection it was fetched under, as `forTagPath`, and anything that + // makes a claim about membership checks it first. Without that the badge + // on a detail card would keep asserting a verdict from a level the + // reader has left. + var bodies = {} + var bodyFetches = {} // key -> in flight + // key -> its 48-bit manifest key collided with another content's, so it + // can never be resolved and must never be asked about again. ONLY that: + // "the server returned nothing" used to land here too, which made a + // transient disagreement permanent -- the class of bug that left cards + // blank with no way to recover. Nothing-at-all now goes to the backoff + // below, where a retry is possible. + var bodyMisses = {} + // key -> when it may be asked about again. A FAILED request is not a + // miss: the content may well exist and the server may recover. But + // without a wait, ensureBodies runs every frame and re-asks every frame: + // one broken response produced 3565 requests and 3565 console warnings + // before anyone looked. The backoff doubles, so a server that stays + // broken is asked about once a minute rather than sixty times a second. + var bodyRetryAt = {} + var bodyBackoff = {} + var BODY_RETRY_MS = 1500 + var BODY_RETRY_MAX_MS = 60000 + // One body lookup at a time. The sweep runs every frame and used to fire + // again the instant an answer landed, so a single navigation split into + // seven requests (measured entering /Unity, 23 marks). Each one pays PHP + // startup, metadata and index loading before it reads a single body, so + // the split multiplies a fixed cost the reader gains nothing from. + var bodySweepInFlight = false + var navGeneration = 0 // see navigate(): guards against stale responses + var loadingTimer = null + // The in-flight gate renewal, held as the promise and not as a boolean: all + // three service callers can get 428 in the same frame, and a boolean would + // only tell the second and third arrivals that someone else was working -- + // it gives them nothing to await. Holding the promise makes them join it. + var gateRenewal = null + var gateFailedAt = 0 + // A failed renewal is not retried for this long. The proof is ~65,536 + // SHA-256 digests (16 bits), so an unattended tab facing a permanently + // broken gate would otherwise burn that every time the body sweep's own + // backoff came due -- silently, for as long as the tab stayed open. + var GATE_COOLDOWN_MS = 30000 + // Not a cap on how long the reader waits -- a cap on how long the slot + // above can stay occupied. AccessGate.solve() has no abort, so without + // this a proof that never finishes would block every later renewal. + // Generous on purpose: the readers this feature exists for are on mobile, + // and the same devices are the slowest at the proof. Raise it if + // ACCESS_GATE_POW_BITS ever goes above 16. + var GATE_POW_TIMEOUT_MS = 60000 + + // ---- helpers ---------------------------------------------------------- + + function natCaseCompare(a, b) { + var ax = String(a).toLowerCase().match(/(\d+|\D+)/g) || [] + var bx = String(b).toLowerCase().match(/(\d+|\D+)/g) || [] + for (var i = 0; i < Math.max(ax.length, bx.length); i++) { + if (ax[i] === undefined) return -1 + if (bx[i] === undefined) return 1 + var an = parseInt(ax[i], 10) + var bn = parseInt(bx[i], 10) + if (!isNaN(an) && !isNaN(bn)) { + if (an !== bn) return an - bn + } else if (ax[i] !== bx[i]) { + return ax[i] < bx[i] ? -1 : 1 + } + } + return 0 + } + + function parseTagPath(tagPath) { + var segments = [] + tagPath.split("/").forEach(function (part) { + var tags = part.split(",").map(function (t) { return t.trim() }).filter(Boolean) + if (tags.length > 0) segments.push(tags) + }) + return segments + } + + function normalizeSegments(segments) { + var result = [] + segments.forEach(function (segment) { + var tags = [] + segment.forEach(function (tag) { + tag = tag.trim() + if (tag !== "" && tags.indexOf(tag) === -1) tags.push(tag) + }) + tags.sort(natCaseCompare) + if (tags.length > 0) result.push(tags) + }) + return result + } + + function tagPathOf(segments) { + return segments.map(function (s) { return s.join(",") }).join("/") + } + + function basePath() { + var path = location.pathname + var index = path.toLowerCase().indexOf("/:tagmap") + return index >= 0 ? path.slice(0, index) : path + } + + function buildHref(segments) { + var tagPart = segments.map(function (s) { + return s.map(encodeURIComponent).join(",") + }).join("/") + return basePath() + "/:tagmap" + (tagPart ? "/" + tagPart : "") + + "?layer=" + encodeURIComponent(state.layer) + } + + function currentUrlSegments() { + var index = location.pathname.toLowerCase().indexOf("/:tagmap") + var raw = index >= 0 + ? decodeURIComponent(location.pathname.slice(index + "/:tagmap".length)) + : "" + return parseTagPath(raw) + } + + function segmentsLabel(segments) { + if (segments.length === 0) return "TagMap" + return segments.map(function (s) { return s.join(", ") }).join(" / ") + } + + function selectedTags() { + var tags = [] + state.segments.forEach(function (segment) { + segment.forEach(function (tag) { if (tags.indexOf(tag) === -1) tags.push(tag) }) + }) + return tags + } + + /** + * The payload entry for a tag, or null when the current response says + * nothing about it. Callers must render nothing rather than substitute a + * different field: a tag with no `count` is not a tag with count = total. + */ + function tagEntry(tag) { + if (!state.data) return null + var lists = [state.data.coTags, state.data.suggestedTags, state.data.chipTags] + for (var i = 0; i < lists.length; i++) { + var list = lists[i] || [] + for (var j = 0; j < list.length; j++) { + if (list[j].tag === tag) return list[j] + } + } + return null + } + + function localize(ja, en) { + var lang = document.documentElement.lang || "ja" + return lang.indexOf("ja") === 0 ? ja : en + } + + function readMeta(name) { + var element = document.getElementsByName(name).item(0) + return element ? element.content : null + } + + // Only the star field needs randomness now; layout hashing lives in + // tagmap-layout.js, where it is covered by tests. + function prng(seed) { + var a = seed >>> 0 + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0 + var t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + // ---- theme ------------------------------------------------------------ + + var paletteCache = null + + function palette() { + if (paletteCache) return paletteCache + var style = getComputedStyle(document.documentElement) + function v(name, fallback) { + var value = style.getPropertyValue(name).trim() + return value !== "" ? value : fallback + } + paletteCache = { + bg: v("--tagmap-bg", "#fcfcfb"), + ink: v("--tagmap-ink", "#0b0b0b"), + inkSecondary: v("--tagmap-ink-secondary", "#52514e"), + muted: v("--tagmap-muted", "#898781"), + gridline: v("--tagmap-gridline", "#e1e0d9"), + surface: v("--tagmap-node-surface", "#ffffff"), + accent: v("--tagmap-tag", "#3498db"), + accentRing: v("--tagmap-tag-ring", "#006399"), + focus: v("--tagmap-focus", "#d6991f"), + focusRing: v("--tagmap-focus-ring", "#a87500"), + reading: v("--tagmap-reading", "#e12885"), + // A content's two colours. Kept apart from `surface`, which the + // DOM chrome also wears: tinting that would tint the breadcrumb + // and the info card too. + contentDisc: v("--tagmap-content-disc", "#bec8d1"), + contentPaper: v("--tagmap-content-paper", "#ffffff"), + contentEdge: v("--tagmap-content-edge", "#808080"), + star: v("--tagmap-star", ""), + } + return paletteCache + } + + function onThemeChanged() { + // ThemeChanger runs its callbacks before it sets the theme attribute. + requestAnimationFrame(function () { + paletteCache = null + buildStarLayer() + }) + } + + // ---- api -------------------------------------------------------------- + + /** + * Mints a fresh gate token in place, without a page load. + * + * The token is bound to the address it was issued to (IPv4 /32, IPv6 /64), + * so it dies the moment a reader moves between Wi-Fi and a mobile network, + * and every service call then answers 428. This used to be + * location.reload(), which cost two further document loads -- the reload + * carries the stale cookie, so the .htaccess prefilter, which only tests + * that a cookie EXISTS, passes it through to the inline challenge, which + * reloads again -- and discarded everything the URL does not carry: the + * camera, the open card, every fetched title, the ancestor context, and + * any pages past the first. + * + * None of that was necessary. ContentsViewer.js has re-minted in place + * since the gate landed; only this file believed a page load was the one + * way in. + * + * NOT ensureToken(): AccessGate.hasToken() is a substring test on + * document.cookie, so it sees presence and never validity. An + * IP-invalidated token is present, so ensureToken() would answer {ok:true} + * about the very cookie that just failed. Only acquireToken() re-mints. + * + * Resolves true when a usable cookie now exists, false otherwise. It never + * rejects: the caller's only question is whether it may re-send, and a + * boolean is the whole answer. + * + * No onProgress: solve() offers one, but there is nowhere on this map to + * put an attempt counter that would not be more alarming than the 2px + * line setLoading() already raises. + * + * @param force skip the failure cooldown (the reader asked, in person) + */ + function renewGate(force) { + if (gateRenewal) return gateRenewal + if (!force && gateFailedAt && performance.now() - gateFailedAt < GATE_COOLDOWN_MS) { + return Promise.resolve(false) + } + // Unreachable in practice: access-gate.js and this file are both + // deferred and it is listed first, and no 428 can arrive before + // init(). Guarded anyway, because a missing global would throw inside + // a promise chain and surface as an invisible unhandled rejection + // rather than as the toast the reader needs. + if (!window.AccessGate) { + console.warn("TagMap: gate renewal needed but AccessGate is not loaded") + return Promise.resolve(false) + } + + // setLoading is a class toggle, not a refcount, so a foreground + // request still in flight loses its line one RTT early when this + // settles. Refcounting it means auditing six call sites and fixing + // navigate()'s cache-hit path, which calls setLoading(false) with no + // matching true -- more risk than the 50ms of comfort is worth. + setLoading(true) + var timer = null + var renewal = Promise.race([ + window.AccessGate.acquireToken(state.serviceUri).then(function (result) { + if (!result.ok) console.warn("TagMap: gate renewal failed (" + result.reason + ")") + return result.ok + }, function (error) { + console.warn("TagMap: gate renewal threw", error) + return false + }), + // A race, not a cancellation: solve() has no abort path. The + // orphan keeps hashing and writes the cookie whenever it lands, + // which only helps -- some later request simply works. + new Promise(function (resolve) { + timer = setTimeout(function () { resolve(false) }, GATE_POW_TIMEOUT_MS) + }), + ]).then(function (ok) { + // Identity, not truthiness -- the same discipline as + // navGeneration. Only the current renewal may clear the slot, so + // an orphan finishing a minute late cannot wipe a live one. + if (gateRenewal === renewal) { + gateRenewal = null + gateFailedAt = ok ? 0 : performance.now() + } + clearTimeout(timer) + setLoading(false) + return ok + }) + gateRenewal = renewal + return renewal + } + + /** + * The reader asking is the strongest signal there is, and it outranks the + * cooldown, which exists only to stop unattended retries. It lives here so + * the cooldown has one owner and the retry buttons do not reach into it. + */ + function clearGateCooldown() { + gateFailedAt = 0 + } + + /** + * Posts to the tag-map service and resolves the parsed body. + * + * Owns the transport and nothing else. What a body MEANS differs by + * caller -- canonical_mismatch is not an error to fetchState but any + * body.error is one to the body lookups -- so interpretation stays where + * it already is. + * + * The gate policy lives here because it has to. It used to be a predicate, + * gateExpired(), and before that the predicate lived only in fetchState(), + * which left the body lookups retrying on their backoff forever: marks + * stayed anonymous, one console warning a minute, and fetchOneBody() + * reported the empty response as "in the manifest but not in the + * population" -- blaming the data for what the gate had done. Recovery now + * re-SENDS the request, and a predicate cannot do that; it does not know + * the request. So what is shared is the call itself. + * + * A 428 body is `{"error":"challenge_required"}`, which carries no items. + * It is never parsed here, so it can never be read as an answer about any + * key. + * + * One re-send, ever. A second 428 means the token was already wrong when + * it was minted -- the address moved again during the proof -- which + * re-minting cannot fix, so it surfaces as the server's own error string. + * + * @param signal optional. The re-send reuses it, so a reader who navigated + * during the proof gets AbortError from an already-aborted controller, + * which navigate() and loadMore() already handle. Nothing here has to + * know about navigation. + */ + function askService(form, signal) { + function send(retried) { + // The same FormData can be sent twice: fetch extracts a fresh body + // from it on every send. (A Request or a stream body could not.) + return fetch(state.serviceUri + "/tagmap-service.php", { + method: "POST", + body: form, + signal: signal, + }).then(function (response) { + if (response.status === 428) { + if (retried) throw new Error("challenge_required") + return renewGate().then(function (ok) { + if (!ok) throw new Error("challenge_required") + return send(true) + }) + } + // Without this a 500 fell through to response.json() and + // reached the caller as a SyntaxError, so the sweep logged a + // parser error under "body lookup failed" and navigate() put + // "Unexpected token '<'" in a toast -- blaming the client's + // parser for the server's outage. + if (!response.ok) throw new Error("http_" + response.status) + return response.json() + }) + } + return send(false) + } + + /** + * @param channel "nav" for a selection change, "page" for paging. They get + * separate controllers: sharing one meant either cancelled the other, so + * paging silently killed an in-flight navigation and vice versa. + */ + + function fetchState(segments, offset, isRetry, channel, scope) { + channel = channel || "nav" + if (controllers[channel]) controllers[channel].abort() + controllers[channel] = new AbortController() + var signal = controllers[channel].signal + + var form = new FormData() + form.append("contentPath", state.contentPath) + form.append("tagPath", tagPathOf(segments)) + form.append("offset", offset || 0) + form.append("limit", PAGE_SIZE) + if (scope) form.append("scope", scope) + // No CSRF token: this endpoint is a public READ, guarded by AccessGate + // and ValidateAccessPrivilege, and it never validated the token we + // used to send. Sending an ignored field only implies a check exists. + + // Two one-shot retries, independent of each other: askService may + // re-send once for the gate, and this may recurse once for a canonical + // path. The ceiling for one call is therefore four service requests + // and one seed -- 428, renew, canonical_mismatch, 428, renew. The + // recursion carries isRetry, so there is no third round. + return askService(form, signal).then(function (body) { + if (body.error === "canonical_mismatch" && !isRetry) { + return fetchState(parseTagPath(body.canonical), offset, true, channel, scope) + } + if (body.error) throw new Error(body.error) + return body + }) + } + + /** + * Asks for the bodies of the marks that have grown big enough to show + * one, nearest the middle first. + * + * Driven by the level of detail rather than by paging, so every content + * is treated alike: a page would name whichever bodies arrived first and + * leave the rest anonymous, and the reader would take the difference for + * a fact about the contents. + * + * Summaries come with the title, in one request. Asking for titles first + * and summaries later, once a mark grew big enough to show one, read the + * same content file TWICE to save a single decode (~3-4 ms) -- and a + * whole file read costs more than that on any filesystem, so the staging + * was a loss everywhere rather than a trade. + */ + function ensureBodies() { + if (!state.nebula) return + if (bodySweepInFlight) return // see bodySweepInFlight + var soon = Layout.cardFor(contentPx() * BODY_LEAD) + if (!soon.showTitle) return // nothing would have room to show it + var now = performance.now() + var shape = markShape() + var wanted = [] + // The list the card pass draws, not state.nebula.points: during a + // bloom the two differ, and a mark that is drawn but not in the + // fetch list is a mark that can appear blank. Marks on their way OUT + // are skipped -- they belong to the selection being left, so their + // keys have no referent in the one being asked about. + marksThisFrame().forEach(function (mark) { + if (mark.leaving) return + if (bodies[mark.key]) return + if (bodyMisses[mark.key] || bodyFetches[mark.key]) return + if (bodyRetryAt[mark.key] && now < bodyRetryAt[mark.key]) return + var screen = worldToScreen(mark.x, mark.y) + if (!nearViewport(screen, shape.w, shape.h)) return + wanted.push({ + key: mark.key, + distance: Math.hypot(screen.x - cssW / 2, screen.y - cssH / 2), + }) + }) + if (wanted.length === 0) return + + // Nearest the middle first: that is where the reader is looking, and + // a bounded request has to choose. + wanted.sort(function (a, b) { return a.distance - b.distance }) + var keys = [] + for (var i = 0; i < wanted.length && keys.length < BODY_BATCH; i++) { + if (keys.indexOf(wanted[i].key) < 0) keys.push(wanted[i].key) + } + keys.forEach(function (key) { bodyFetches[key] = true }) + + // The selection these keys came from. A manifest key is 48 bits of a + // path hash and carries no level, so it only has a referent inside + // the selection that minted it -- the server resolves it against + // that selection's population, which is the same list the manifest + // was built from. Sending "" asked the server to resolve them + // against the tagged corpus instead, where a content selected by + // NAME similarity does not appear: those marks were drawn and could + // never be named (8 of /Arduino's 65). + var askedFor = tagPathOf(state.segments) + var form = new FormData() + form.append("contentPath", state.contentPath) + form.append("tagPath", askedFor) + form.append("scope", "keys") + form.append("keys", keys.join(",")) + form.append("fields", "full") // see the note above ensureBodies + + // Held across a gate renewal too, which is exactly what it is for: the + // proof takes seconds, and the mutex keeps the per-frame sweep quiet + // for all of them at no extra cost. Every exit clears it -- the .then + // below and the .catch at the end -- so a 428 can no longer strand it. + bodySweepInFlight = true + askService(form) + .then(function (body) { + bodySweepInFlight = false + if (body.error) throw new Error(body.error) + // This answer is kept even if the reader has moved on. The + // keys and the tagPath left in the SAME request, so the + // server resolved exactly these keys in exactly the + // selection they came from -- the answer cannot be about a + // different level, and a title does not expire when the + // camera does. Discarding it here (which an earlier draft + // did, by analogy with navGeneration) only threw away + // correct titles and asked for them again. + // + // What a late answer CAN carry is a stale membership + // verdict, so each item is stamped with the selection it was + // resolved in; see `bodies`. + var byKey = {} + ;(body.items || []).forEach(function (item) { + byKey[item.key] = byKey[item.key] === undefined ? item : null + }) + var absent = 0 + keys.forEach(function (key) { + delete bodyFetches[key] + var item = byKey[key] + if (item) { + item.forTagPath = askedFor + bodies[key] = item + delete bodyRetryAt[key] + delete bodyBackoff[key] + return + } + if (byKey[key] === null) { + // Two items for one key: the 48-bit key collided. + // Permanent, and correctly so -- the same two paths + // will collide next time, and naming an article with + // another article's title is worse than leaving it + // unnamed. + bodyMisses[key] = true + return + } + // Nothing at all. This should not be reachable: the key + // came from the manifest of this very selection, and the + // server resolves it against that selection's own + // population. If it happens, contents changed under us + // or something is broken -- both worth asking about + // again, so it goes to the backoff rather than being + // silenced forever. + bodyBackoff[key] = Math.min(BODY_RETRY_MAX_MS, + (bodyBackoff[key] || BODY_RETRY_MS / 2) * 2) + bodyRetryAt[key] = performance.now() + bodyBackoff[key] + absent++ + }) + if (absent > 0) { + // Worth a line: the manifest and the population are + // supposed to be the same list, so a gap is a real + // inconsistency and not a normal outcome. + console.warn("TagMap: " + absent + " of " + keys.length + + " key(s) are in the manifest of " + (askedFor || "(root)") + + " but not in its population") + } + ensureLoop() + }) + .catch(function (error) { + bodySweepInFlight = false + // A failure is not an answer, so these keys are not misses -- + // but they must wait before being asked about again, or the + // per-frame sweep turns one bad response into thousands. + var wait = 0 + keys.forEach(function (key) { + delete bodyFetches[key] + bodyBackoff[key] = Math.min(BODY_RETRY_MAX_MS, + (bodyBackoff[key] || BODY_RETRY_MS / 2) * 2) + bodyRetryAt[key] = performance.now() + bodyBackoff[key] + wait = Math.max(wait, bodyBackoff[key]) + }) + // One line per failure, not one per key: the point is that it + // failed, and a flood buries whatever is above it. + console.warn("TagMap: body lookup failed for " + keys.length + + " key(s); retrying in " + Math.round(wait / 1000) + "s", error) + }) + } + + // ---- placement: the nested-circle model -------------------------------- + // + // World coordinates are ABSOLUTE: one unit is one content radius, at + // every depth. A circle's size therefore states a content count that + // means the same thing everywhere on the map, and the camera scale is a + // real quantity -- CSS px per content radius -- clamped at + // Layout.zoomCeiling() so the zoom range is finite. + // + // Entering a group does not renormalise anything. The group's circle + // BLOOMS into the level it becomes (it has to hold a mark per + // (content, group) pair, so it needs about 2.2x the radius), and the + // parent's field is dilated about that circle by the same factor, which + // pushes every sibling radially outward without re-packing. See + // Layout.composeBloom for why a re-pack is not an option. + + /** + * Rebuilds the scene from the payloads on hand. Pure: same payloads in, + * same scene out, so a revisit reproduces the picture exactly and a + * shared URL shows the same thing to everyone. + */ + function buildScene() { + var data = state.data + state.universe = {} + state.members = [] + state.layout = { slots: [], byTag: {} } + state.levelR = 0 + state.enteredHull = null + state.labels = null + // Held in the outgoing level's world coordinates, so it cannot + // survive a scene rebuild. + meltPath = null + if (!data) return + + // A child is a GROUP of tags (the server merges tags that cover an + // identical set of contents), keyed by the OR segment that selects + // it. Sized by `count` at every depth -- at the root that equals the + // global total, so there is no special case and no channel carrying + // two different quantities. + var derived = levelLayout(data) + if (!derived) return + var children = derived.children + var layout = derived.layout + state.groupRemap = derived.groupRemap + state.layout = { slots: layout.anchors, byTag: layout.byTag } + state.packExtent = layout.packExtent + state.levelR = derived.levelR + state.members = children.map(function (child) { + return { + tag: child.tag, count: child.entry.count, + reach: child.entry.reach, total: child.entry.total, + } + }) + + state.layout.slots.forEach(function (slot, index) { + var entry = children[index].entry + state.universe[slot.tag] = { + tag: slot.tag, + tags: entry.tags || [entry.tag], + kind: "child", + x: slot.x, + y: slot.y, + r: slot.r, + count: entry.count, + reach: entry.reach, + total: entry.total, + slot: slot, + } + }) + + // Name suggestions are not children of this selection, so they sit in + // the free band rather than inside. Uniform size on purpose: their + // number is a global total while a child's is an in-selection count, + // and encoding two different quantities in one channel would invite a + // false comparison. + ;(data.suggestedTags || []).forEach(function (t, i) { + if (state.universe[t.tag]) return + var angle = -Math.PI / 2 + (i % 2 === 0 ? 1 : -1) * Math.ceil((i + 1) / 2) * 0.16 + state.universe[t.tag] = { + tag: t.tag, + kind: "suggestion", + x: Math.cos(angle) * GHOST_RING_PAD * state.levelR, + y: Math.sin(angle) * GHOST_RING_PAD * state.levelR, + r: Layout.territoryRadius(1), + count: t.count, + total: t.total, + } + }) + + buildAncestors() + buildNebula() + } + + /** + * Puts the child groups' own contents inside their circles, when there + * are few enough of them to be worth showing in place. + * + * The former server-rendered view did this: below a threshold it expanded + * every group and listed its contents, above it showed only counts. The + * reason still holds -- making someone enter a circle to discover it + * holds one item is a wasted move, and with the contents visible the + * reader can also SEE that a content belonging to two groups appears in + * both, which no count can convey. + * + * Needs the `children` scope, so it fills in when that arrives; until + * then the circles simply show their counts. + */ + /** + * How many contents each pair of groups has in common, read straight off + * the manifest. This is the structure the old layout threw away: it + * ordered groups by count alone, so sharing partners could end up on + * opposite sides of the field. + */ + /** + * A level's territories, from its payload. The ONLY place a level is + * laid out. + * + * It has to be the only place, because a level is laid out twice: once as + * the current level, and again as an ancestor when the reader is inside + * one of its groups. buildAncestors used to redo the derivation and left + * out orderBySharing, so the same parent had two different packings -- + * measured at 5.6 world units apart, which put the camera 118 px wrong + * every time the reader left a level. A duplicated derivation is exactly + * the shape of that bug. + * + * @return {children, layout, levelR, groupRemap} or null without `reach` + */ + function levelLayout(data) { + if (!data) return null + var cached = sceneCache[data.tagPath] + if (cached && cached.data === data) return cached.derived + var children = (data.coTags || []).map(function (t) { + return { tag: t.tag, entry: t } + }) + if (children.length === 0) { + return { + children: children, groupRemap: {}, + layout: Layout.layoutAnchors([]), + levelR: Layout.levelRadiusFor(0, (data.stats || {}).directContents || 0), + } + } + // Sized by `reach` -- how many contents ENTERING the group yields -- + // never by `count`, this view's tally. They differ by up to 4x + // (Arduino: 33 and 65), and a circle drawn from `count` is not the + // size of the view behind it. Without `reach` there is no honest size + // to draw, so the level stays empty rather than wrong. + for (var i = 0; i < children.length; i++) { + if (typeof children[i].entry.reach !== "number") { + console.warn("TagMap: payload carries no coTags[].reach, which is the only " + + "honest size for a group; drawing nothing rather than sizing from count") + return null + } + } + + // Who sits beside whom follows from who shares contents, so a content + // in two groups lands between neighbours instead of halfway across + // the field. Sizes are still the reaches; only the order changes. + var serverOrder = children + children = Layout.orderBySharing(children, sharingMatrix(data, children)) + // The manifest numbers groups in the SERVER's order, so reordering + // them here means every membership index has to be translated. Miss + // this and each content is assigned to the wrong group entirely -- + // silently, because the indices stay in range. + var groupRemap = {} + for (var oldIndex = 0; oldIndex < serverOrder.length; oldIndex++) { + for (var newIndex = 0; newIndex < children.length; newIndex++) { + if (children[newIndex].tag === serverOrder[oldIndex].tag) { + groupRemap[oldIndex] = newIndex + break + } + } + } + + var layout = Layout.layoutAnchors(children.map(function (child) { + return { tag: child.tag, reach: child.entry.reach } + })) + var derived = { + children: children, + groupRemap: groupRemap, + layout: layout, + levelR: Layout.levelRadiusFor( + layout.packExtent, (data.stats || {}).directContents || 0), + } + sceneCache[data.tagPath] = { data: data, derived: derived } + return derived + } + + /** + * The outlines of a level's groups, in that level's own coordinates. + * + * Needed for ancestors as well as for the current level: a group drawn as + * a soft body and then, on being entered, as a hard circle changes SHAPE + * discontinuously even when its position and radius do not. Every region + * this renderer draws is a contour; nothing is an arc. + * + * @return {contours, marks, byTag} or null + */ + function levelContours(data, derived) { + if (!data || !derived || !Array.isArray(data.memberships)) return null + var slot = sceneCache[data.tagPath] + if (slot && slot.data === data && slot.shapes) return slot.shapes + var anchors = derived.layout.anchors + var rows = data.memberships.map(function (row) { + return [row[0], row[1].map(function (g) { + return g === -1 ? -1 : (derived.groupRemap[g] === undefined ? -1 : derived.groupRemap[g]) + })] + }) + var marks = Layout.placeMarks(rows, anchors, { levelRadius: derived.levelR }) + + var byGroup = [] + for (var g = 0; g < anchors.length; g++) byGroup.push([]) + marks.forEach(function (mark) { + if (byGroup[mark.group]) byGroup[mark.group].push(mark) + }) + + var contours = [] + var byTag = {} + for (var k = 0; k < anchors.length; k++) { + var anchor = anchors[k] + var members = byGroup[k] + var field = members.length === 0 + ? [{ x: anchor.x, y: anchor.y, r: Layout.kernelRadius(anchor.r, 1) }] + : (function () { + var radius = Layout.kernelForPoints(members, anchor.r) + return members.map(function (mark) { + return { x: mark.x, y: mark.y, r: radius } + }) + })() + var outline = Layout.contour(field) + var entry = { + tag: anchor.tag, index: k, reach: anchor.reach, + members: members.length, rings: outline.rings, + area: outline.area, anchor: outline.anchor, + } + contours.push(entry) + byTag[anchor.tag] = entry + } + // The level's own boundary: how far its contents reach, per + // direction. A radial hull rather than a metaball, because a single + // field over a whole level cannot be sampled finely enough to be + // trusted -- measured on the root, a level-wide contour left 135 of + // 411 marks OUTSIDE its own outline. See Layout.radialHull. + var boundary = Layout.radialHull(marks, Layout.territoryRadius(1)) + var shapes = { + contours: contours, marks: marks, byTag: byTag, boundary: boundary, + } + if (slot && slot.data === data) slot.shapes = shapes + return shapes + } + + /** Rings as one Path2D, in whatever coordinates they were given. */ + function ringsToPath(rings, scale, offsetX, offsetY) { + var path = new Path2D() + var k = scale === undefined ? 1 : scale + var dx = offsetX || 0, dy = offsetY || 0 + rings.forEach(function (ring) { + if (ring.length < 2) return + path.moveTo(ring[0].x * k + dx, ring[0].y * k + dy) + for (var i = 1; i < ring.length; i++) { + path.lineTo(ring[i].x * k + dx, ring[i].y * k + dy) + } + path.closePath() + }) + return path + } + + function sharingMatrix(data, children) { + var rows = data.memberships + if (!Array.isArray(rows) || rows.length === 0) return null + var sharing = {} + for (var i = 0; i < rows.length; i++) { + var groups = rows[i][1] + if (!groups || groups.length < 2) continue + for (var a = 0; a < groups.length; a++) { + for (var b = a + 1; b < groups.length; b++) { + var ta = children[groups[a]] && children[groups[a]].tag + var tb = children[groups[b]] && children[groups[b]].tag + if (!ta || !tb) continue + if (!sharing[ta]) sharing[ta] = {} + if (!sharing[tb]) sharing[tb] = {} + sharing[ta][tb] = (sharing[ta][tb] || 0) + 1 + sharing[tb][ta] = (sharing[tb][ta] || 0) + 1 + } + } + } + return sharing + } + + function buildNebula() { + state.nebula = null + var data = state.data + if (!data || !Array.isArray(data.memberships)) return + var derived = levelLayout(data) + if (!derived) return + var levelR = state.levelR + if (!(levelR > 0)) return + + // The same marks and outlines an ANCESTOR of this level would be + // drawn with -- one derivation, cached against the payload. When the + // reader enters a group, the level they arrive at reuses the very + // rings its parent was already drawing, so the boundary they clicked + // is the same object rather than an equal one recomputed. + var shapes = levelContours(data, derived) + if (!shapes) return + + state.nebula = { + // Each mark carries its content's `key`. That is the identity + // duplication gave up in the geometry, and it is what lets a + // content present at two levels slide between them instead of + // being replaced. + points: shapes.marks, + contours: shapes.contours, + // The same contours keyed by tag. Already built by sceneShapes; + // kept so the label pass can reach a group's own anchor without + // scanning the list every frame. + contourByTag: shapes.byTag, + anchors: derived.layout.anchors, + levelR: levelR, + // What is actually DRAWN. The territories fit inside INNER_FILL + // of the level by construction, but each outline adds a kernel + // halo, so this is measured rather than derived. + extent: (function () { + var extent = levelR + shapes.contours.forEach(function (contour) { + contour.rings.forEach(function (ring) { + ring.forEach(function (vertex) { + var reach = Math.hypot(vertex.x, vertex.y) + if (reach > extent) extent = reach + }) + }) + }) + return extent + })(), + packExtent: derived.layout.packExtent, + truncated: !!data.manifestTruncated, + } + } + + /** + * The chain of enclosing levels, from whichever prefix payloads we have. + * + * Each ancestor is placed so the level below sits exactly at the slot it + * occupies inside it -- the algebraic inverse of entering -- so an + * ancestor arriving late pops in additively and nothing already drawn + * moves. Ancestors we have no payload for are simply absent; the current + * level never depends on them. + */ + function buildAncestors() { + var chain = [] + var steps = [] + var childLevelR = state.levelR + for (var depth = state.segments.length; depth > 0; depth--) { + var parentSegments = state.segments.slice(0, depth - 1) + var parent = state.payloads[cacheKeyOf(parentSegments)] + if (!parent) break + var parentDerived = levelLayout(parent) + if (!parentDerived) break + var parentLayout = parentDerived.layout + var parentSlots = { slots: parentLayout.anchors, byTag: parentLayout.byTag } + // The parent's groups are keyed by their OR segment, and the + // segment we came in through IS one of those keys, so a lookup by + // the joined name finds it whether it was one tag or several. + var slot = Layout.slotForSegment(parentSlots, [state.segments[depth - 1].join(",")]) + || Layout.slotForSegment(parentSlots, state.segments[depth - 1]) + if (!slot) break + // The bloom: entering this circle grew it into the level below, + // and dilated the parent's field about it by the same factor. + var beta = slot.r > 0 ? childLevelR / slot.r : 0 + if (!(beta > 0)) break + steps.push({ x: slot.x, y: slot.y, beta: beta }) + var parentLevelR = parentDerived.levelR + chain.push({ + slot: slot, payload: parent, segments: parentSegments, + slots: parentLayout.anchors, levelR: parentLevelR, + chainTo: Layout.composeBloom(steps.slice()), + // An ancestor's groups are drawn as the soft bodies they were, + // not as circles: a shape that hardens into an arc the moment + // you enter it is a discontinuity the camera cannot hide. + shapes: levelContours(parent, parentDerived), + }) + childLevelR = parentLevelR + } + state.bloomChain = steps + // The outline of the circle we came in through, in OUR coordinates. + // This is the level's boundary: what the reader clicked, kept as the + // soft body it was rather than replaced by a circle. + // The boundary the reader clicked, as a hull in OUR coordinates, so + // it can be interpolated with this level's own hull angle by angle. + // Both are star-shaped about the level centre, which is exactly what + // a radial representation buys. + state.enteredHull = null + if (chain.length > 0 && chain[0].shapes) { + var came = state.segments[state.segments.length - 1] + var entered = chain[0].shapes.byTag[came.join(",")] + if (entered && entered.rings.length > 0) { + var into = chain[0].chainTo + var mapped = entered.rings.map(function (ring) { + return ring.map(function (v) { + return { x: v.x * into.scale + into.x, y: v.y * into.scale + into.y } + }) + }) + state.enteredHull = Layout.radialHullOfRings(mapped, 0, 0) + } + } + + // The ancestors' other children, in the current level's space. These + // are the siblings: after entering a child they stay exactly where + // they were, dimmed, instead of scattering to unrelated positions. + state.ancestors = [] + // One circle per TAG across the whole screen, and the circle nearest + // to where you are wins. Matching on tags rather than on a group's + // joined key matters because the keys need not line up: a segment the + // reader composed themselves ("C#,Cpp") is not a key in the parent, + // where C# and Cpp are separate groups, and a group that is merged at + // one level can be split at another. + var drawnTags = {} + state.layout.slots.forEach(function (slot) { + var node = state.universe[slot.tag] + ;(node && node.tags ? node.tags : slot.tag.split(",")).forEach(function (tag) { + drawnTags[tag] = true + }) + }) + + for (var i = 0; i < chain.length && i < ANCESTORS_DRAWN; i++) { + var link = chain[i] + var depth = i + 1 + var mapped = link.chainTo + // The tags we entered through at this level are represented by + // the boundary we are inside; drawing them again behind it shows + // the reader the very thing they just stepped into. + state.segments[state.segments.length - 1 - i].forEach(function (tag) { + drawnTags[tag] = true + }) + var visible = link.slots.filter(function (slot) { + return !slot.tag.split(",").some(function (tag) { return drawnTags[tag] }) + }) + // Claim them so a further ancestor does not repeat them either. + visible.forEach(function (slot) { + slot.tag.split(",").forEach(function (tag) { drawnTags[tag] = true }) + }) + state.ancestors.push({ + depth: depth, + segments: link.segments, + // The BOUNDARY scales: it has to keep containing a field that + // spread apart inside it. + x: mapped.x, + y: mapped.y, + r: link.levelR * mapped.scale, + // Where this ancestor's own space sits in ours, so its + // outlines can be drawn without being rebuilt. + map: mapped, + shapes: link.shapes, + boundaryHull: link.shapes ? link.shapes.boundary : null, + children: visible.map(function (slot) { + var shape = link.shapes ? link.shapes.byTag[slot.tag] : null + return { + tag: slot.tag, + // Where this sibling would take you, and how far back + // up it lives. Computed here so the hit test, the + // popup and the drag all read one answer instead of + // re-deriving it from the ancestor three times. + segments: link.segments.concat([[slot.tag]]), + depth: depth, + // What its circle's size already states: the count + // you get by entering it. The popup shows this, so + // the number and the size cannot disagree. + reach: slot.reach, + x: slot.x * mapped.scale + mapped.x, + y: slot.y * mapped.scale + mapped.y, + // The RADIUS does not. Only the circle that was + // entered grew, into the level you are now in; the + // rest were pushed apart, and a radius states a + // content count that must not change because the + // reader navigated. + r: slot.r, + rings: shape ? shape.rings : null, + } + }), + }) + } + } + + /** + * The current level, as one circle. A selection is a single set however + * it was built -- `A,B` is a union and `A/B` an intersection, but either + * way what you are looking at is one group of contents -- so it gets one + * boundary. Null at the root, which is a level without a boundary. + */ + function containerRegion() { + if (state.segments.length === 0) return null + return { x: 0, y: 0, r: state.levelR } + } + + /** + * Contents drawn as dots sit *directly* in the selection: every tag they + * carry is already selected, so they fall into none of the child clouds. + * A content that belongs to a child is that child's business — its count + * is on the child's label, and its dot appears once you go in. Same split + * the server-rendered tag view made ("non" group versus the tag groups). + * + * The server decides membership (`direct=1`); the client must not infer + * it from `coTags`, which is capped, nor from a page of items, which is + * ordered explicit-first and so biases a page-local estimate low. + * + * Angles come from the index alone, so layoutBand(10) is an exact prefix + * of layoutBand(30): appending a page cannot move an existing dot. + */ + // ---- camera ----------------------------------------------------------- + + function worldToScreen(wx, wy) { + return { + x: cssW / 2 + (wx - camera.x) * camera.scale, + y: cssH / 2 + (wy - camera.y) * camera.scale, + } + } + + function screenToWorld(px, py) { + return { + x: (px - cssW / 2) / camera.scale + camera.x, + y: (py - cssH / 2) / camera.scale + camera.y, + } + } + + // ---- zoom-driven transitions ------------------------------------------ + // + // Pushing against the zoom ceiling IS the gesture for going in, and + // against the floor for coming out. It needs no new control: the reader + // keeps zooming and the map keeps obliging. + // + // The ceiling alone is not enough now that it sits at the card scale -- + // about 93 px per content radius, which is 6.8x past the fit on + // /Arduino, far too much wheel-work to be the way in. So OCCUPANCY is + // the other trigger: a group that fills most of the viewport is one the + // reader is plainly heading into. Measured, that is reachable -- a + // 14-content group needs k >= 28, a 33-content group k >= 18. + // + // Tap and drag stay the primary path regardless. + var ZOOM_PUSH_CAP = 0.35 // log units of push we bother to accumulate + var ZOOM_PUSH_COMMIT = 0.22 // and how much commits the transition + var ZOOM_DWELL_MS = 120 // held continuously, so a flick cannot commit + var ZOOM_COOLDOWN_MS = 320 + var ZOOM_MAX_PER_BURST = 3 + // A group filling this share of the viewport commits going in; the + // current level shrinking to this share commits coming out. The gap is a + // factor of 1.8 in scale, and the two tests are on DIFFERENT objects (a + // child versus this level), so they cannot chase each other. + var ZOOM_ENTER_OCCUPANCY = 0.62 + var ZOOM_LEAVE_OCCUPANCY = 0.34 + // A wheel burst: macOS keeps sending decaying events for up to 1.5 s + // after the fingers lift and does not flag them. Rather than trying to + // detect inertia, the stream is segmented and one burst counts as one + // gesture -- inertia is part of the same burst, so a burst ends once. + var WHEEL_BURST_GAP_MS = 140 + // Last-resort backstop. A transition loop navigates for ever and the + // reader cannot escape it, which is the worst failure this feature can + // have, so it is disabled for the session rather than merely rate-limited. + // + // It should be unreachable: the dwell and the cooldown together mean a + // transition costs at least 440 ms of sustained pushing, and the burst + // cap stops at three, so six inside two seconds cannot happen. Measured + // by hammering the wheel: the burst cap fires and the backstop never + // does. It stays as insurance against a future change breaking the cap, + // which is exactly the kind of change whose damage the reader cannot undo. + var RUNAWAY_WINDOW_MS = 2000 + var RUNAWAY_MAX = 6 + + var zoomPush = 0 // signed, in log-scale units, against a clamp + var zoomDesired = 0 // what the reader asked for, clamps aside + // Visible give past a clamp, in log units: about 15%. Enough to feel, + // not enough to be mistaken for the zoom still working. + var ZOOM_BAND = 0.14 + // How far past a clamp the reader may ASK to go. Beyond this the request + // stops accumulating, so a long spin does not leave a huge spring to + // unwind when they let go. + var ZOOM_REACH = 1.6 + var zoomPending = null // {kind, tag, since} + var zoomBurst = null // {entryPath, transitions, endsAt} + var zoomCooldownUntil = 0 + var zoomHistory = [] // recent commit times, for the backstop + var zoomDisabled = false + var wheelBurstTimer = null + var meltPath = null // {key, path} for the pair currently held together + var marksFrame = null // {t, marks} the interpolated list, once per frame + // Screen rects of the cards drawn this frame, so the group names can + // avoid them. Rebuilt every frame by drawNebulaCards. + var cardRects = [] + // Screen rects of the NAMES drawn this frame, with what each one names. + // A group's name is the one part of it that is certainly visible, never + // overlaps anything (place() collision-checks it) and is never under a + // card, so it is the tap target the group can rely on. Rebuilt every + // frame by drawLabels, the same way cardRects is. + var labelHits = [] + // Why a name did not get drawn, counted per frame. + // + // place() is a first-come arbiter with four ways to say no, and every one + // of them was silent: it returns false and NO caller looks at the return. + // Content marks have had `named`/`anonymous` since the card tier landed, + // and that pair is what made "drawn but nameless" a fixable bug rather + // than a matter of opinion. Group names had nothing at all, so the same + // failure was invisible on the other half of the picture. + // + // `wanted` counts the groups that reached the arbiter plus the ones the + // radius gate turned away, so `placed / wanted` is the number to move. + var nameStats = null + // Corner radius of a fully-grown card, in CSS px. The mark interpolates + // from a circle (corner = half its side) to this. + // + // Fixed px rather than a share of the card, for the same reason the type + // is: a card grows 89x63 -> 198x140 across the reading zooms, and a + // radius that grew with it would turn the big one into a lozenge. So the + // rounding is constant and only its SHARE of the card shrinks, from 9.5% + // to 4.5%. + // + // The value is a choice, not a derivation -- the site's own content card + // (.card-item) is 2px and the map's DOM chrome is 5-12px, so this sits + // between them by decision. + var CARD_CORNER_PX = 8 + // The connector from the detail card to the marks it is about. + var LINK_WIDTH_PX = 1.5 + // The edge of the content being read. Every other mark gets 1. + var READING_EDGE_PX = 2 + // One trip of the pulse along a connector, and one dash-length step of + // the ring around the mark it points at. Slow on purpose: this is a + // place marker, not an alert, and anything quick enough to catch the eye + // twice is quick enough to stop the reader finishing a sentence. + var LINK_PULSE_MS = 2600 + var READING_DASH_MS = 1400 + // Half the pulse's length, as a share of the line. The rest of the line + // stays drawn, dimmed: the connector's job is to say WHERE, and a line + // that is only visible where the pulse happens to be stops doing it. + var LINK_PULSE_LEN = 0.16 + var LINK_DIM_ALPHA = 0.4 + // Long dashes with short gaps: the edge still reads as an edge, and the + // rotation is what you notice rather than the dashes themselves. + var READING_DASH = [14, 5] + // The outline of the group a zoom is aimed at, flowing the way the + // reader is pushing. + var INTENT_DASH = [10, 6] + var INTENT_FLOW_MS = 1800 + // The drift of a speck-sized mark: how far, how slowly, and the object + // returned when there is none, so the callers allocate nothing per mark + // per frame in the common case. + // The drift field: how fast it turns over, how far it swings, and how + // broad it is. + // + // FLOAT_WAVE is in WORLD units, where marks are CONTENT_PITCH (2.6) + // apart, so it is really "how many marks to a wave" -- here about two + // and a third. + // + // It was 15, nearly six marks to a wave, and at any zoom that puts a + // dozen marks on screen that is most of the picture inside one wave: it + // read as the whole field sliding rather than flexing. Measured at + // contentPx 45 while walking it down, with the outlines warped by the + // same field: + // + // wave neighbours differ outline vs true isoline marks escaping + // 15 4.62 px 14.5 px 0 / 568 + // 9 6.18 px 15.4 px 0 / 568 + // 6 7.85 px 17.0 px 0 / 568 + // 4 9.08 px 22.3 px 0 / 568 + // + // The cost of going finer is fidelity: a shorter wave has a steeper + // gradient, and the warped outline is only the true isoline of the + // warped marks to within that gradient over one kernel radius. At 6 the + // error is 17 px against a 180.8 px kernel margin -- 9% -- and no mark + // left its own outline at any wavelength tried. + var FLOAT_MS = 11000 + var FLOAT_WAVE = 6 + // The finer octave, as a multiple of the base frequency. Over one + // CONTENT_PITCH it advances 3.4 * 2.6 / 15 = 0.59 of a radian, so it is + // what makes two neighbours differ; the base is what keeps a whole + // region drifting together. Together they close a touching pair by about + // a third of the amplitude -- 4 px of the 16 px between two titles at + // FLOAT_MAX_PX. + var FLOAT_OCTAVE = 3.4 + var FLOAT_K = 1.1 + var FLOAT_MAX_PX = 11 + var ZERO_DRIFT = { x: 0, y: 0 } + // Endpoints drawn this frame, for tests. One entry per instance of the + // content the detail card is showing, so a test counts lines instead of + // a reader counting them in a screenshot. + var linkEnds = [] + // The bloom in progress: ancestor radii are multiplied by + // factor^(1-t) so they start at the screen size they had and settle to + // the absolute size their content count states. `t` is also the clock + // for the mark morph below. + var bloom = null + // The outgoing level's marks, kept so a content that exists at both + // levels can SLIDE rather than teleport. Measured without it: 57 of the + // root's contents also live in /Arduino, and they jumped 10-38 world + // units -- the camera was continuous and the contents were not. `map` + // takes the outgoing world into the incoming one, the same similarity + // the ancestor chain uses. + var morph = null + // Layouts and outlines, by tag path. A level is laid out twice -- once as + // the current level, once as an ancestor when the reader is inside one of + // its groups -- and the second time used to REDO the whole derivation: + // the packing, the marks and a marching-squares pass per group. Keeping + // them means entering reuses the very rings the parent was drawing, so + // the boundary is the same object rather than an equal one, and the work + // is paid once per payload rather than once per navigation. + // + // Safe to keep because a level's geometry is a pure function of its + // payload: same payload in, same picture out, no clock and no randomness. + var sceneCache = {} + + /** + * Where a looping decoration is in its cycle, 0..1. + * + * The one clock in the draw path, and it drives style only -- a dash + * offset, a gradient stop. No geometry reads it, which is the line the + * determinism tests actually draw: they stub Math.random and Date.now + * around the LAYOUT module, and what they protect is "same payload, same + * picture". A moving dash does not move a vertex. + * + * Wall-clock rather than a frame count, so the speed is the same on a + * 30 Hz panel and a 120 Hz one. Frozen at 0 under reduced motion, which + * makes every caller below static without any of them testing for it. + */ + function motionPhase(periodMs) { + if (reducedMotion) return 0 + return (performance.now() % periodMs) / periodMs + } + + /** A content radius in CSS px. The level-of-detail indicator. */ + function contentPx() { + return Layout.CONTENT_R * camera.scale + } + + function zoomCeiling() { + return Layout.zoomCeiling() + } + + /** + * How far back the reader may pull: far enough to SEE the level they + * would leave to. + * + * The first version put the floor at a fixed fraction of the fit, which + * measured 1.3x of pull-back at every level -- effectively pinning the + * camera to the fit, so zooming out died almost immediately and read as + * the control breaking. Pulling back is how you decide whether to leave, + * so the floor is the scale at which the PARENT is framed, and pushing + * past that is the leave gesture. + * + * At the root there is nothing to leave to, so the floor is generous + * rather than derived: pulling back just shows empty sky, which is + * harmless and stops the wheel feeling broken. + */ + function zoomFloor() { + var ancestor = state.ancestors && state.ancestors.length ? state.ancestors[0] : null + var extent = ancestor + ? Math.hypot(ancestor.x, ancestor.y) + ancestor.r + : drawnExtent() * ROOT_PULLBACK + if (!(extent > 0)) return fitScale() + return Math.min((Math.min(cssW, cssH) * FIT_FILL) / (2 * extent), zoomCeiling()) + } + + /** + * The scale actually drawn, given what the reader has asked for. + * + * Past a clamp the surplus is compressed rather than discarded, so the + * wall is something you can feel pushing against. Without this the zoom + * simply stops: no give, no cue, and then -- once the dwell elapses -- a + * navigation the reader did not see coming. The band IS the affordance + * for a gesture that has no control to look at. + */ + function bandedScale(desired) { + var ceiling = zoomCeiling(), floor = zoomFloor() + if (desired > ceiling) { + return ceiling * Math.exp(Math.min(ZOOM_BAND, Math.log(desired / ceiling) * 0.45)) + } + if (desired < floor) { + return floor / Math.exp(Math.min(ZOOM_BAND, Math.log(floor / desired) * 0.45)) + } + return desired + } + + /** + * Applies a zoom step, and reads the intent off how far past the clamp + * the reader is asking to go. + * + * `zoomDesired` is what they asked for and may exceed the clamps; the + * camera shows the banded version of it. Deriving the push from the + * surplus rather than accumulating it means letting go and pushing again + * starts over, which is what a reader expects of a spring. + */ + function applyZoom(factor, px, py) { + var anchor = screenToWorld(px, py) + var ceiling = zoomCeiling(), floor = zoomFloor() + if (!(zoomDesired > 0)) zoomDesired = camera.scale + zoomDesired = Math.min(ceiling * ZOOM_REACH, + Math.max(floor / ZOOM_REACH, zoomDesired * factor)) + + zoomPush = zoomDesired > ceiling ? Math.log(zoomDesired / ceiling) + : (zoomDesired < floor ? -Math.log(floor / zoomDesired) : 0) + zoomPush = Math.max(-ZOOM_PUSH_CAP, Math.min(ZOOM_PUSH_CAP, zoomPush)) + // The pending intent is considerZoomTransition's alone. Clearing it + // here whenever the push was zero killed the OCCUPANCY intent on + // every event -- push is zero for the whole range between the floor + // and the ceiling, which is exactly where occupancy does its work -- + // so the dwell never accumulated and going in still needed the + // ceiling. Measured: the intent was re-seeded every 47 ms and only + // committed once the push finally crossed, at k = 107 of a 93 ceiling. + + camera.scale = bandedScale(zoomDesired) + camera.x = anchor.x - (px - cssW / 2) / camera.scale + camera.y = anchor.y - (py - cssH / 2) / camera.scale + considerZoomTransition(px, py, factor > 1 ? 1 : (factor < 1 ? -1 : 0)) + } + + /** Lets the band spring back once the reader stops pushing. */ + function releaseZoom() { + if (zoomDesired > 0) { + var settled = clampScale(zoomDesired) + if (Math.abs(settled - camera.scale) > 1e-6) { + cameraTarget = { x: camera.x, y: camera.y, scale: settled } + } + } + zoomDesired = 0 + zoomPush = 0 + zoomPending = null + } + + /** + * Which group the reader is pushing into, by the OUTLINE rather than the + * territory: the outline is the shape they can see. + * + * Returns null when two outlines both contain the point. Overlap is rare + * now that a shared content is drawn in each of its groups, but it is not + * impossible, and guessing between two would send the reader somewhere + * they did not choose. Waiting for them to move the pointer costs a + * moment; going to the wrong level costs their place. + */ + function zoomCandidate(px, py) { + if (!state.nebula) return null + var world = screenToWorld(px, py) + var found = null + for (var i = 0; i < state.nebula.contours.length; i++) { + var contour = state.nebula.contours[i] + if (!Layout.pointInPolygon(contour.rings, world.x, world.y)) continue + if (found) return null + found = contour + } + return found + } + + /** + * What share of the smaller viewport dimension a circle of world radius + * `r` covers. 1 means it just spans the screen. + */ + function occupancy(r) { + var side = Math.min(cssW, cssH) + return side > 0 ? (2 * r * camera.scale) / side : 0 + } + + function considerZoomTransition(px, py, direction) { + if (zoomDisabled) return + var now = performance.now() + if (now < zoomCooldownUntil) return + if (zoomBurst && zoomBurst.transitions >= ZOOM_MAX_PER_BURST) return + + // Which group the pointer is in, needed by both triggers going in. + var candidate = zoomCandidate(px, py) + + // Two ways to commit, and either is enough. + // + // The push is the reader shoving past a clamp. It no longer suffices + // on its own: the ceiling now sits at the card scale, 6.8x past the + // fit on /Arduino, which is far too much wheel-work to be the way in. + // + // So occupancy is the other one. A group that fills most of the + // viewport is one the reader is plainly heading into, and that is + // reachable -- measured, a 14-content group crosses 0.62 at k = 28, + // a 33-content group at k = 18, both inside the fit-to-ceiling range. + // It requires the reader to still be zooming that way, because a big + // group is big whichever direction they came from. + var kind = null + if (zoomPush >= ZOOM_PUSH_COMMIT) kind = "in" + else if (zoomPush <= -ZOOM_PUSH_COMMIT) kind = "out" + else if (direction > 0 && candidate + && occupancy(groupRadius(candidate.tag)) >= ZOOM_ENTER_OCCUPANCY) kind = "in" + else if (direction < 0 + && occupancy(drawnExtent()) <= ZOOM_LEAVE_OCCUPANCY) kind = "out" + if (!kind) { zoomPending = null; return } + if (kind === "out" && state.segments.length === 0) return + + var tag = null + if (kind === "in") { + // Null when two outlines both hold the point: see zoomCandidate. + if (!candidate) { zoomPending = null; return } + tag = candidate.tag + } + + // Held continuously: a single flick that happens to land past the + // threshold must not navigate. + if (!zoomPending || zoomPending.kind !== kind || zoomPending.tag !== tag) { + zoomPending = { kind: kind, tag: tag, since: now } + return + } + if (now - zoomPending.since < ZOOM_DWELL_MS) return + + commitZoomTransition(kind, tag, now) + } + + /** + * A group's radius in world units, as the reader sees it: the outline it + * is drawn with, falling back to the territory the layout gave it. + */ + function groupRadius(tag) { + var node = state.universe ? state.universe[tag] : null + return node && node.r > 0 ? node.r : 0 + } + + function commitZoomTransition(kind, tag, now) { + zoomHistory = zoomHistory.filter(function (at) { return now - at < RUNAWAY_WINDOW_MS }) + zoomHistory.push(now) + if (zoomHistory.length > RUNAWAY_MAX) { + zoomDisabled = true + zoomPending = null + showToast(localize( + "ズームでの移動を停止しました(タップで移動できます)", + "Zoom navigation stopped; tap to move instead")) + return + } + + zoomPush = 0 + zoomPending = null + zoomCooldownUntil = now + ZOOM_COOLDOWN_MS + if (zoomBurst) zoomBurst.transitions++ + + // Within a burst the path is replaced, and the entry path is pushed + // once when the burst ends -- so crossing three levels in one gesture + // leaves one history entry, and a wobble that goes in and straight + // back out leaves none. + var replace = !!zoomBurst + // `keepCamera`: the reader is still pushing, so the camera stays + // where the re-anchor put it and their next wheel step continues from + // there. Easing to the new level's fit instead fought the gesture -- + // it dragged the scale back while they were asking for more of it. + if (kind === "in") { + narrowDown(tag, { replace: replace, kind: "zoom-in", keepCamera: true }) + } else { + leaveLevel({ replace: replace, kind: "zoom-out", keepCamera: true }) + } + } + + /** Ends the current wheel burst, settling its history entry. */ + function endZoomBurst() { + if (!zoomBurst) return + var burst = zoomBurst + zoomBurst = null + zoomPush = 0 + zoomPending = null + releaseZoom() + if (burst.transitions === 0) return + var finalPath = tagPathOf(state.segments) + if (finalPath === burst.entryPath) { + // In and straight back out: the reader is where they started, so + // the journey does not belong in their history. + history.replaceState(null, "", buildHref(state.segments)) + return + } + history.replaceState(null, "", burst.entryHref) + history.pushState(null, "", buildHref(state.segments)) + } + + /** Forgets any pending zoom intent. Called when a gesture is abandoned. */ + function abortZoomIntent() { + zoomPush = 0 + zoomPending = null + if (zoomBurst) { zoomBurst.transitions = 0; zoomBurst = null } + } + + /** + * How much an ancestor's territory radius is inflated right now. + * + * 1 once the bloom has settled: a radius states a content count and must + * not lie about it for longer than the transition takes. + */ + function bloomFactor() { + return bloom ? Math.pow(bloom.factor, 1 - bloom.t) : 1 + } + + /** What the level occupies on screen, outlines and halos included. */ + function drawnExtent() { + if (state.nebula && state.nebula.extent > 0) return state.nebula.extent + return state.levelR > 0 ? state.levelR : Layout.CONTENT_PITCH + } + + /** The scale at which the level exactly fills the viewport. */ + function fitScale() { + var extent = drawnExtent() + return extent > 0 ? (Math.min(cssW, cssH) * FIT_FILL) / (2 * extent) : 1 + } + + /** + * The camera scale is CSS px per content radius, clamped absolutely + * rather than by a ratio against a fit that used to change every level. + * + * The ceiling is the point of the whole absolute scale: it is the scale + * at which a content's card holds a full excerpt, so there is a real end + * to zooming in and its justification is legibility rather than taste. + */ + function clampScale(scale) { + return Math.min(zoomCeiling(), Math.max(zoomFloor(), scale)) + } + + function hairline() { + return 1 / camera.scale + } + + /** Frames the current level. Only the camera moves. */ + function fitCurrentLevel() { + cameraTarget = { x: 0, y: 0, scale: clampScale(fitScale()) } + if (reducedMotion) { + camera.x = cameraTarget.x + camera.y = cameraTarget.y + camera.scale = cameraTarget.scale + cameraTarget = null + } + } + + /** + * Keeps the picture still at the instant the level changes. + * + * Two things move that the camera has to follow, and I removed this + * function once on the mistaken grounds that the absolute scale had made + * it unnecessary. The absolute scale removed the RENORMALISATION, not the + * need to follow: + * + * 1. the coordinate ORIGIN moves to the circle being entered, so every + * world coordinate shifts by -c; + * 2. that circle BLOOMS into the level it becomes, radius r -> beta*r. + * + * Measured without this: the picture jumped 264 px and the boundary grew + * 1.55x in one frame. + * + * The arithmetic is Layout.bloomCamera, where `node --test` can hold it: + * deleting it from the renderer is invisible to the test suite, which is + * how it went missing the first time. + */ + function reanchorForBloom(centre, beta, direction) { + var moved = Layout.bloomCamera(camera, centre, beta, direction) + if (!moved) return false + camera.x = moved.x + camera.y = moved.y + camera.scale = moved.scale + // An ancestor's territory keeps its absolute radius, which states a + // content count -- but the camera scale just changed by beta, so at + // this instant those circles would jump by 1/beta. The factor starts + // at beta (their old screen size) and eases to 1 (their honest size) + // alongside the camera. + bloom = { factor: beta, t: 0 } + // The per-frame mark list is keyed by bloom.t, so a new bloom that + // starts at the same t the last one ended on would be served the + // PREVIOUS level's marks for its first frame. + marksFrame = null + // The reader's pending zoom request belonged to the OLD level's + // scale. Left standing, the very next wheel event recomputed the + // camera from it and snapped the scale by 2.5x in one frame -- + // measured, and inside a single level, so it read as the map + // breaking rather than as navigation. Cleared, the next event + // re-seeds from the camera the re-anchor just placed. + zoomDesired = 0 + zoomPush = 0 + zoomPending = null + // A pinch carries a frame of its own, and it is the same shape as a + // camera: gesture.anchor is a world point, gesture.startScale a + // scale. The pointermove handler recomputes the camera from both on + // every event, so leaving them in the old frame does not merely add + // an error -- it writes the old frame straight back and ERASES the + // re-anchor above. Measured entering /Arduino: the wheel's scale + // stepped 13.43 -> 9.87 across the transition (by 1/beta) and its + // camera moved 14.5 world units, while the pinch went 12.80 -> 14.11 + // and moved 0.35, as though no level had changed. + // + // Being a camera's shape, it moves by the camera's own function. Note + // startDist is a distance on the SCREEN, so it is frame-independent + // and must not be touched; the direction test downstream compares + // finger distances and is unaffected for the same reason. + if (gesture && gesture.mode === "pinch") { + var frame = Layout.bloomCamera( + { x: gesture.anchor.x, y: gesture.anchor.y, scale: gesture.startScale }, + centre, beta, direction) + if (frame) { + gesture.anchor = { x: frame.x, y: frame.y } + gesture.startScale = frame.scale + } + } + return true + } + + /** True when `outer` is a prefix of `inner`, segment for segment. */ + function isPrefixOf(outer, inner) { + if (outer.length > inner.length) return false + for (var i = 0; i < outer.length; i++) { + if (outer[i].join(",") !== inner[i].join(",")) return false + } + return true + } + + function stepCamera() { + if (bloom) { + bloom.t += (1 - bloom.t) * EASE + if (bloom.t > 0.995) { bloom = null; morph = null } + } + if (!cameraTarget) return + camera.x += (cameraTarget.x - camera.x) * EASE + camera.y += (cameraTarget.y - camera.y) * EASE + camera.scale *= Math.pow(cameraTarget.scale / camera.scale, EASE) + var near = Math.abs(cameraTarget.x - camera.x) < 1 + && Math.abs(cameraTarget.y - camera.y) < 1 + && Math.abs(cameraTarget.scale - camera.scale) < 0.002 + if (near) { + camera.x = cameraTarget.x + camera.y = cameraTarget.y + camera.scale = cameraTarget.scale + cameraTarget = null + } + } + + // ---- navigation ------------------------------------------------------- + + function applyData(data, options) { + options = options || {} + var fromSegments = state.segments + var wasBuilt = !!state.data + + state.payloads[data.tagPath] = data + // Captured here, not by the caller: the camera keeps easing while a + // request is in flight, so a "before" taken any earlier is measured + // against a different camera and the comparison is meaningless. + var transitionBefore = wasBuilt ? TM.snapshot() : null + // What the OUTGOING level drew for the level we are entering, or the + // step out of it. Captured before the scene is rebuilt, because the + // camera has to be re-anchored against it afterwards -- see + // reanchorForBloom for why the absolute scale did not remove the need. + // The marks on screen right now, by identity. Kept before the scene + // is rebuilt, because rebuilding replaces every one of them. + var outgoing = state.nebula ? state.nebula.points : null + var step = null + if (wasBuilt) { + if (fromSegments.length + 1 === data.segments.length + && isPrefixOf(fromSegments, data.segments)) { + var entering = Layout.slotForSegment(state.layout, + [data.segments[data.segments.length - 1].join(",")]) + || Layout.slotForSegment(state.layout, + data.segments[data.segments.length - 1]) + if (entering && entering.r > 0) { + step = { direction: "in", centre: entering, slotR: entering.r } + } + } else if (data.segments.length + 1 === fromSegments.length + && isPrefixOf(data.segments, fromSegments)) { + var out = state.bloomChain && state.bloomChain.length ? state.bloomChain[0] : null + if (out) step = { direction: "out", centre: out, beta: out.beta } + } + } + + state.segments = data.segments + state.data = data + buildScene() + if (!wasBuilt) { camera.x = 0; camera.y = 0 } + + // The instant after re-anchoring and before framing: the only moment + // at which "nothing moved" is observable, since both happen in this + // one synchronous block. Exposed so a test can assert it. + // The bloom: the ratio between the level the circle became and the + // circle it was. Known only now, because it needs the incoming + // level's own radius. + var bloomBeta = 0 + if (step) { + bloomBeta = step.direction === "in" + ? (step.slotR > 0 ? state.levelR / step.slotR : 0) + : step.beta + if (!reanchorForBloom(step.centre, bloomBeta, step.direction)) { + camera.x = 0 + camera.y = 0 + } + if (!(bloomBeta > 0)) step = null + } else if (wasBuilt) { + // A breadcrumb leap or a fresh load shares no frame of reference + // with what was on screen, so there is nothing to be continuous + // with; it simply frames the new level. + camera.x = 0 + camera.y = 0 + bloom = null + } + + // The outgoing marks, and the map that puts them in the new level's + // coordinates. Nothing is recomputed for them: they are the same + // instances the last frame drew. + if (outgoing && step) { + var mapBeta = step.direction === "in" ? bloomBeta : 1 / bloomBeta + morph = { + marks: outgoing, + map: step.direction === "in" + ? { scale: mapBeta, x: -step.centre.x * mapBeta, y: -step.centre.y * mapBeta } + : { scale: mapBeta, x: step.centre.x, y: step.centre.y }, + } + } else { + morph = null + } + + // Captured AFTER the re-anchor: the whole point of the snapshot is to + // say whether the picture moved, and the re-anchor is part of the + // same synchronous instant. Taken before it, it reported the camera + // unchanged and the level jumped 67 px -- which is what the reader + // sees, but it hid the cause. + TM.lastTransition = { + from: fromSegments, + to: data.segments, + // What moved the reader here, so the history policy and the + // continuity claims can be checked separately per gesture. + kind: options.kind || (options.fromHistory ? "history" : "tap"), + before: transitionBefore, + at: TM.snapshot(), + } + + var label = segmentsLabel(state.segments) + + // history.state stays empty: the URL is the identity and memory holds + // the view. popstate reads the URL and the payload cache. + if (!options.fromHistory) { + if (options.replace) history.replaceState(null, "", buildHref(state.segments)) + else history.pushState(null, "", buildHref(state.segments)) + } + document.title = data.tagPath === "" ? "TagMap" : "TagMap: " + label + + // The popup is anchored to a tag OF THIS LEVEL, so it cannot survive + // one: its subject is gone by definition. The card is anchored to a + // content, and a content can be at both levels -- see + // syncInfoCardToLevel, which decides per content rather than per + // navigation. + closePopup() + syncInfoCardToLevel() + renderBreadcrumb() + rebuildSrList() + updateNote() + // A zoom-driven transition leaves the camera where the re-anchor put + // it: the reader is mid-gesture and still pushing, and framing the + // new level would pull the scale back against them. Every other route + // in -- a tap, a breadcrumb, history -- frames it. + if (options.keepCamera) cameraTarget = null + else fitCurrentLevel() + // Ancestors are context, not a prerequisite: fetch them in the + // background and let them pop in without moving anything. + ensureAncestors() + } + + function cacheKeyOf(segments) { + var tagPath = tagPathOf(segments) + return tagPath === "" ? "" : "/" + tagPath + } + + /** + * Fetches the payloads of the enclosing levels, so their boundaries and + * their other children (the siblings) can be drawn. Never blocks the + * current level and never touches the camera. + */ + function ensureAncestors() { + for (var depth = state.segments.length - 1; depth >= 0; depth--) { + var prefix = state.segments.slice(0, depth) + var key = cacheKeyOf(prefix) + if (state.payloads[key] || ancestorFetches[key]) continue + ancestorFetches[key] = true + ;(function (segments, cacheKey) { + fetchState(segments, 0, false, "ancestor-" + cacheKey).then(function (data) { + state.payloads[data.tagPath] = data + buildAncestors() + }).catch(function () { + // Context is optional; a failure just leaves it absent. + }).then(function () { + delete ancestorFetches[cacheKey] + }) + })(prefix, key) + } + } + + function navigate(segments, options) { + options = options || {} + segments = normalizeSegments(segments) + var tagPath = tagPathOf(segments) + var cacheKey = tagPath === "" ? "" : "/" + tagPath + // Every navigation claims a generation, and a response only applies if + // it still holds the newest one. Without this, a slow request for an + // abandoned selection would land later and drag the view back to it, + // pushing its own history entry on the way. + var generation = ++navGeneration + var cached = state.payloads[cacheKey] + if (cached) { + // Also drop anything in flight: a cache hit is a navigation too. + if (controllers.nav) controllers.nav.abort() + setLoading(false) + applyData(cached, options) + return + } + setLoading(true) + fetchState(segments, 0, false).then(function (data) { + setLoading(false) + if (generation !== navGeneration) return + applyData(data, options) + }).catch(function (error) { + if (generation !== navGeneration) return + // Clear the progress line first: an abort is still the end of this + // request, and returning before it left the bar up forever. + setLoading(false) + if (error.name === "AbortError") return + if (error.message === "limit_exceeded") { + // Retrying cannot help: the server will reject it again. + showToast(localize("これ以上たどれません", "Cannot go deeper")) + return + } + if (error.message === "challenge_required") { + // The gate could not be renewed in place. Say what happened + // rather than showing the wire's word for it, and let the + // reader decide: their asking outranks the failure cooldown, + // so clear it before re-entering. + showToast(localize("接続が変わったため確認が必要です", "Your connection changed -- verification failed"), function () { + clearGateCooldown() + navigate(segments, options) + }) + return + } + showToast(localize("読み込みに失敗しました", "Failed to load") + " (" + error.message + ")", function () { + navigate(segments, options) + }) + }) + } + + /** + * Enters a child group. `key` is its OR segment ("A" or "A,B"), which is + * what the group's circle is keyed by, so a merged group enters as the + * union it was drawn as. + */ + function narrowDown(key, options) { + var node = state.universe[key] + var tags = node && node.tags ? node.tags : String(key).split(",") + var selected = selectedTags() + tags = tags.filter(function (tag) { return selected.indexOf(tag) < 0 }) + if (tags.length === 0) return + if (state.segments.length >= MAX_DEPTH) { + showToast(localize("これ以上絞り込めません", "Cannot narrow down further")) + return + } + if (tags.length > MAX_WIDTH) { + showToast(localize("一度に選べるタグ数を超えています", "Too many tags at once")) + return + } + navigate(state.segments.concat([tags]), options) + } + + /** + * Enters the union of the given tags, as a NEW level. + * + * `enterTogether(['A','B'])` at `/Library` gives `/Library/A,B` -- the + * contents of Library that carry A or B. Widening the last segment + * instead (`/Library,A,B`) is a SUPERSET of what you were looking at, so + * the gesture labelled "view together" would silently un-narrow the view + * and destroy the parent relationship the user just gestured about. Only + * at the root did the two forms coincide, which is why the old behaviour + * looked right there and wrong everywhere else. + */ + function enterTogether(keys) { + var selected = selectedTags() + var segment = [] + keys.forEach(function (key) { + var node = state.universe[key] + var tags = node && node.tags ? node.tags : String(key).split(",") + tags.forEach(function (tag) { + if (segment.indexOf(tag) === -1 && selected.indexOf(tag) < 0) segment.push(tag) + }) + }) + if (segment.length === 0) return + if (segment.length > MAX_WIDTH) { + showToast(localize("一度に選べるタグ数を超えています", "Too many tags at once")) + return + } + if (state.segments.length >= MAX_DEPTH) { + showToast(localize("これ以上絞り込めません", "Cannot narrow down further")) + return + } + navigate(state.segments.concat([segment])) + } + + /** Steps out to the enclosing level. */ + function leaveLevel(options) { + if (state.segments.length === 0) return + navigate(state.segments.slice(0, state.segments.length - 1), options) + } + + /** Adds a tag to the CURRENT level's segment, widening this same level. */ + function widenLevel(tag) { + if (state.segments.length === 0) return enterTogether([tag]) + var last = state.segments[state.segments.length - 1].slice() + if (last.indexOf(tag) >= 0) return + last.push(tag) + if (last.length > MAX_WIDTH) { + showToast(localize("このセグメントは満杯です", "This segment is full")) + return + } + navigate(state.segments.slice(0, state.segments.length - 1).concat([last])) + } + + function removeTag(segmentIndex, tag) { + var segments = state.segments.map(function (s) { return s.slice() }) + segments[segmentIndex] = segments[segmentIndex].filter(function (t) { return t !== tag }) + navigate(segments) + } + + function removeSegment(segmentIndex) { + var segments = state.segments.map(function (s) { return s.slice() }) + segments.splice(segmentIndex, 1) + navigate(segments) + } + + function loadMore() { + var contents = state.data.contents + var forPath = state.data.tagPath + setLoading(true) + fetchState(state.segments, contents.offset + contents.limit, false, "page").then(function (data) { + setLoading(false) + // The selection may have changed while this was in flight; a page + // of the old selection must not be appended to the new one. + if (!state.data || state.data.tagPath !== forPath) return + state.data.contents.items = state.data.contents.items.concat(data.contents.items) + state.data.contents.offset = data.contents.offset + state.data.contents.hasMore = data.contents.hasMore + state.payloads[state.data.tagPath] = state.data + rebuildSrList() + }).catch(function (error) { + setLoading(false) + if (error.name === "AbortError") return + // Paging had no retry at all, so a failure here was terminal until + // the reader navigated away and back. It has one now, because a + // failed gate renewal is exactly the kind of failure that a second + // attempt fixes. + showToast(localize("読み込みに失敗しました", "Failed to load"), function () { + if (error.message === "challenge_required") clearGateCooldown() + loadMore() + }) + }) + } + + // ---- star layer ------------------------------------------------------- + + /** + * The parallax speck field: a depth cue, drawn under everything at a + * quarter of the camera's motion. + * + * Present in BOTH themes. It used to be dark-only, on the reasoning that + * a paper chart has no glowing specks -- true of glowing ones, but the + * paper answer is ink rather than light, and having the depth cue in one + * theme and not the other made the two themes different designs. + * + * The background must never be the loudest thing on screen. Measured + * before this was enforced, the dark field's strongest speck reached + * 3.89:1 against its ground while the faintest content mark managed + * 1.60:1 -- the context out-shouting the subject, the same inversion the + * marks themselves were once guilty of. + * + * A fixed cap could not fix that without making the field invisible: the + * marks fade with distance (starAlpha) while a fixed field does not, so + * a value safe at the far end -- where the marks reach only 1.49:1 -- + * was imperceptible at every zoom a reader actually uses, where they + * reach 3.63:1. So the FIELD fades with the marks instead (see draw()), + * the ratio holds everywhere, and the ink can be strong enough to see. + * + * Both inks carry the same alpha, so neither theme has more background + * than the other. + * + * Seeded, so the same field appears for every reader on every visit. + */ + function buildStarLayer() { + starLayer = null + var colors = palette() + if (!colors.star || cssW === 0) return + var off = document.createElement("canvas") + off.width = Math.ceil(cssW * 1.4) + off.height = Math.ceil(cssH * 1.4) + var octx = off.getContext("2d") + var random = prng(0xC0FFEE) + octx.fillStyle = colors.star + // Radius and count, not alpha, are what made the field invisible on + // paper. A 0.4 px ink dot antialiases away to nothing, and raising + // the alpha could not buy back area -- the loudest speck only got to + // 1.68:1. Night got away with it because a glowing speck on a nearly + // black ground starts at 3.89:1, so it could afford to be tiny. + for (var i = 0; i < STAR_COUNT; i++) { + octx.globalAlpha = 0.12 + random() * 0.5 + octx.beginPath() + octx.arc(random() * off.width, random() * off.height, + STAR_MIN_R + random() * (STAR_MAX_R - STAR_MIN_R), 0, TAU) + octx.fill() + } + starLayer = off + } + + // ---- render ----------------------------------------------------------- + + function ensureLoop() { + if (running || document.hidden) return + running = true + drawErrors = 0 // a deliberate (re)start earns another chance + requestAnimationFrame(frame) + } + + function frame() { + if (document.hidden) { running = false; return } + try { + var began = performance.now() + frameSerial++ + stepCamera() + draw() + positionPopup() + ensureBodies() + recordFrame(performance.now() - began) + drawErrors = 0 + } catch (error) { + // Keep the loop alive: one bad frame must not freeze the map for + // good. Give up only if it keeps failing, so a persistent error + // neither floods the console nor burns a core silently. + drawErrors++ + if (drawErrors === 1 || drawErrors === MAX_DRAW_ERRORS) { + console.error("TagMap draw error:", error) + } + if (drawErrors >= MAX_DRAW_ERRORS) { + running = false + showToast(localize("表示を停止しました", "Rendering stopped"), ensureLoop) + return + } + } + requestAnimationFrame(frame) + } + + /** + * Main-thread cost of a frame, so the budget is measured rather than + * inferred from a frame rate. + * + * The rate on its own says nothing: a display running at 30 Hz gives + * exactly 33.3 ms between frames however cheap the drawing is, and a + * dropped frame and a slow one look identical from the outside. This + * times the work itself. + */ + function recordFrame(ms) { + perf.frames++ + perf.total += ms + if (ms > perf.worst) perf.worst = ms + perf.recent.push(ms) + if (perf.recent.length > PERF_WINDOW) perf.recent.shift() + } + + function draw() { + if (!ctx) return + var colors = palette() + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.fillStyle = colors.bg + ctx.fillRect(0, 0, cssW, cssH) + + if (starLayer) { + var ox = (-camera.x * camera.scale * 0.25) % starLayer.width + var oy = (-camera.y * camera.scale * 0.25) % starLayer.height + // The field carries the same weight the marks do, so it is + // subordinate to them at EVERY zoom rather than only at the one + // the cap was chosen for. Fixed, it had to be set faint enough + // for the far end -- where the marks fade to 1.49:1 -- which + // left it invisible everywhere else. Tracking them, it can be + // strong enough to see (loudest speck 2.08:1 against the marks' + // 3.63:1) and still fades away with them. + ctx.globalAlpha = starAlpha(contentPx()) + for (var x = ox - starLayer.width; x < cssW; x += starLayer.width) { + for (var y = oy - starLayer.height; y < cssH; y += starLayer.height) { + ctx.drawImage(starLayer, x, y) + } + } + ctx.globalAlpha = 1 + } + + ctx.save() + ctx.translate(cssW / 2, cssH / 2) + ctx.scale(camera.scale, camera.scale) + ctx.translate(-camera.x, -camera.y) + + drawGrid(colors) + // Ancestors first, underneath: they are the context you came through. + drawAncestors(colors) + drawNebula(colors) + drawContainer(colors) + drawNebulaStars(colors) + drawDrag(colors) + + ctx.restore() + ctx.globalAlpha = 1 + + // The marks, once they are big enough to hold text. Screen space, + // because the type is a fixed size and would otherwise scale with + // the camera. No collision test: a card's diagonal is CONTENT_PITCH + // and placeMarks keeps marks at least that far apart, so two cards + // cannot reach each other. + drawNebulaCards(colors) + + // The content being read, and the lines tying it to every place it + // sits -- both AFTER the marks. + // + // The lines used to run before them, so that they would "pass UNDER + // them and never cross the text they are pointing at". The first half + // of that was the cost, not the aim: it bought nothing from the mark + // being pointed AT -- the line already stops at that mark's edge, and + // still does -- while burying the line under every unrelated mark it + // crossed on the way. A connector that cannot be followed is not one, + // and being followable clear across the screen is the whole reason + // --tagmap-reading has a hue of its own. + // + // The card's own text stays safe either way: it is DOM, above the + // canvas at z-index 20, so no draw order here can reach it. + drawReadingMarks(colors) + drawInfoLinks(colors) + + // Text last, in screen space, collision-checked as one set. + drawLabels(colors) + } + + /** + * Concentric guides inside the current level, rather than a cartesian + * grid: distance from the centre is what means something here, and a + * level-relative ring cannot develop a scale at which it disappears. + */ + function drawGrid(colors) { + if (state.segments.length === 0) return + ctx.save() + ctx.strokeStyle = colors.gridline + ctx.lineWidth = hairline() + ctx.globalAlpha = 0.7 + GUIDE_RINGS.forEach(function (share) { + ctx.beginPath() + ctx.arc(0, 0, state.levelR * share, 0, TAU) + ctx.stroke() + }) + ctx.restore() + } + + /** + * Is an ancestor's context drawn at all this frame? + * + * Only while the level still reads as a circle. Past this, its boundary + * is off-screen and its children are each larger than the viewport -- a + * wall of arcs that says nothing and competes with the level you are + * actually in. Zoom out and it returns. + */ + function ancestorDrawn(ancestor) { + return ancestor.r * camera.scale <= Math.hypot(cssW, cssH) * 1.4 + } + + /** + * One of an ancestor's children as it is DRAWN this frame, or null. + * + * markShape()'s rule applied to the group layer. Read by drawAncestors, + * by the sibling names and by pickSibling, so what is visible is what is + * touchable -- the three used to decide it separately and two of them had + * already drifted: + * + * - pickSibling applied NONE of these tests. Measured over 36 + * zoom-and-position combinations, it answered "sibling" 4,854 times + * and 1,659 of those named a child the frame had not drawn. Those + * 1,659 are every single answer it gave at contentPx 12 and 26, where + * ancestorDrawn culls the ancestor whole and its 96 children are + * nowhere on screen: dragging blank canvas grabbed one of them + * instead of panning. Now 0, while the 3,195 answers at contentPx 6, + * where the children ARE drawn, are unchanged. + * - the sibling names copied two of the four tests by hand and dropped + * the other two. + * + * `rings.scale` folds bloomFactor() in for the same reason: the draw + * multiplied by map.scale * bloomFactor() while the pick divided by + * map.scale alone, so mid-transition the polygon being tested was not + * the polygon being stroked. One number now, so they cannot differ -- + * a guarantee by construction, since a bloom is too brief to sample. + * + * The viewport test earns nothing for the hit test -- you can only press + * where you are looking, and child.rings sits inside the child.r circle + * this rejects on. It is here because drawAncestors needs it, and + * because a reader should not have to work out which of four tests + * matters to which of three callers. That is the whole point. + */ + function ancestorChildShape(ancestor, child) { + if (!ancestorDrawn(ancestor)) return null + var inflate = bloomFactor() + var worldR = child.r * inflate + var childR = worldR * camera.scale + // Outlines, not fills, and only at a size that still reads as a + // circle: an ancestor's children are magnified by 1/slot.r, so + // filling them turns the context into the loudest thing on screen + // and it competes with the level you are actually in. + if (childR < 3 || childR > Math.hypot(cssW, cssH) * 0.55) return null + var screen = worldToScreen(child.x, child.y) + if (screen.x < -childR || screen.x > cssW + childR + || screen.y < -childR || screen.y > cssH + childR) return null + var map = ancestor.map + return { + worldR: worldR, + childR: childR, + screen: screen, + // Where child.rings lands in world space, or null when it has no + // soft body and the circle is all there is. + rings: child.rings && map && map.scale > 0 + ? { scale: map.scale * inflate, x: map.x, y: map.y } + : null, + } + } + + /** + * The levels you came through: each drawn as a boundary with its other + * children still in place. That IS the trail -- the way back is a shape + * you can see and zoom out into, not a line of dots -- and the siblings + * around you are literally the ones you left behind. + */ + function drawAncestors(colors) { + state.ancestors.forEach(function (ancestor) { + if (!ancestorDrawn(ancestor)) return + + ctx.save() + ctx.globalAlpha = 0.5 + ctx.strokeStyle = colors.focusRing + ctx.lineWidth = hairline() * 1.2 + // Its own boundary is a hull too. Left as an arc it was the one + // hard circle still on screen, and the largest object in it. + if (ancestor.boundaryHull && ancestor.map) { + ctx.stroke(ringsToPath( + [Layout.hullRing(ancestor.boundaryHull, 0, 0)], + ancestor.map.scale * bloomFactor(), ancestor.map.x, ancestor.map.y)) + } else { + ctx.beginPath() + ctx.arc(ancestor.x, ancestor.y, ancestor.r, 0, TAU) + ctx.stroke() + } + + ctx.globalAlpha = 0.45 + ctx.strokeStyle = colors.muted + ctx.lineWidth = hairline() + ancestor.children.forEach(function (child) { + // Which children are drawn, and where, is ancestorChildShape's + // to say -- not restated here, so the hit test and the names + // cannot fall behind an edit made in this loop. + var shape = ancestorChildShape(ancestor, child) + if (!shape) return + if (shape.rings) { + // The soft body it was. Its rings are in the ancestor's + // own coordinates, so they go through the same map its + // centres did -- one similarity, no rebuild. + ctx.stroke(ringsToPath(child.rings, shape.rings.scale, + shape.rings.x, shape.rings.y)) + return + } + ctx.beginPath() + ctx.arc(child.x, child.y, shape.worldR, 0, TAU) + ctx.stroke() + }) + ctx.restore() + }) + } + + /** + * Paths for the current nebula. + * + * The EXTRACTION stays scene data: the root's 106 outlines are ~12,000 + * vertices and cost about 4 ms of marching squares, which is fine once + * per navigation and impossible per frame. Nothing here redoes that. + * + * The Path2D objects were scene data too, and no longer are, because the + * outlines now drift with their contents. Building them from vertices + * that already exist is a different cost from finding those vertices: + * measured on the root, 0.90 ms a frame against a 16.7 ms budget, and + * less at any zoom where the legibility cull takes outlines out of the + * set. When nothing is moving they are still built once and kept. + * + * The legibility cull is the ONLY one. Neither this pass nor drawNebula + * has a viewport test: every legible outline is handed to the rasteriser + * and clipped there. This said "the legibility cull and the viewport", + * which reads as a second condition someone could go and find. + */ + function nebulaPaths() { + var nebula = state.nebula + if (!nebula) return null + if (nebula.paths && (reducedMotion || nebula.pathsAt === fieldClock())) return nebula.paths + + var paths = nebula.contours.map(function (contour) { + var path = new Path2D() + var circle = null + contour.rings.forEach(function (ring) { + if (ring.length < 2) return + // Each vertex reads the field at its OWN position, which is + // what deforms the shape. Moving the whole outline by one + // sample would translate a rigid body instead. + var d = fieldAt(ring[0].x, ring[0].y) + path.moveTo(ring[0].x + d.x / camera.scale, ring[0].y + d.y / camera.scale) + for (var i = 1; i < ring.length; i++) { + d = fieldAt(ring[i].x, ring[i].y) + path.lineTo(ring[i].x + d.x / camera.scale, ring[i].y + d.y / camera.scale) + } + path.closePath() + }) + // A group whose outline is a single point's isoline IS a circle, + // so draw it as one: 60-odd vertices per group, saved on the + // long tail of one-content groups that most levels are made of. + // It has no vertices to warp, so it travels with the one mark it + // is drawn around -- which for a circle is the whole truth. + if (contour.members <= 1 && contour.rings.length === 1) { + var anchor = nebula.anchors[contour.index] + var ad = fieldAt(anchor.x, anchor.y) + circle = { x: anchor.x + ad.x / camera.scale, + y: anchor.y + ad.y / camera.scale, + r: Layout.territoryRadius(anchor.reach) } + } + return { path: path, circle: circle } + }) + + // Which ones get a fill: the biggest by CONTENT COUNT, not by + // enclosed area. A group whose few contents are scattered across the + // level has a large area and little mass, and filling it would say + // the opposite of the truth. + var ranked = nebula.contours + .map(function (contour, index) { return { index: index, reach: contour.reach } }) + .sort(function (a, b) { return b.reach - a.reach }) + nebula.filled = {} + ranked.slice(0, NEBULA_FILLS).forEach(function (entry) { + nebula.filled[entry.index] = true + }) + + // How loudly each outline is drawn. Without this every group shouts + // equally, and since most levels are a long tail of one-content + // groups, the picture is dominated by its least informative lines -- + // measured on the root: 60 of 106 groups hold a single content, and + // their outlines are what turns the centre into a hairball. + var loudest = Math.max.apply(null, nebula.contours.map(function (c) { return c.reach })) + nebula.prominence = nebula.contours.map(function (contour) { + return Math.sqrt(contour.reach) / Math.sqrt(Math.max(1, loudest)) + }) + + nebula.paths = paths + nebula.pathsAt = fieldClock() + return paths + } + + /** + * Which frame the field is on, so a scene's paths are rebuilt once per + * frame rather than once per pass -- drawNebula asks for them twice, for + * the fills and then for the outlines. + */ + function fieldClock() { + return frameSerial + } + + /** + * The nebulae: one soft outline per group, shaped by its own contents. + * + * Where two outlines overlap, a content sits in both groups -- that + * crossing IS the picture of sharing, and it is what the old renderer + * could only say by drawing the same content twice. + */ + function drawNebula(colors) { + var nebula = state.nebula + var paths = nebulaPaths() + if (!paths) return + + // What the reader is pushing into, if anything. Shown while the + // intent builds rather than only once it commits: pushing against the + // ceiling is a gesture with no control to look at, so the map has to + // say which group it heard. + var intent = zoomPending && zoomPending.kind === "in" ? zoomPending.tag : null + var intentStrength = Math.min(1, Math.abs(zoomPush) / ZOOM_PUSH_COMMIT) + + // How readable each outline is at this zoom, as a fade rather than a + // switch, so nothing pops in as you move. + var legible = nebula.contours.map(function (contour) { + var screenR = Layout.territoryRadius(contour.reach) * camera.scale + return Layout.smoothstep(NEBULA_MIN_SCREEN_R * 0.7, NEBULA_MIN_SCREEN_R * 1.4, screenR) + }) + + ctx.save() + ctx.strokeStyle = colors.accentRing + ctx.fillStyle = colors.accent + + // Fills first, then every outline on top, so a small group's line is + // never buried under a big group's wash. + for (var f = 0; f < paths.length; f++) { + if (!nebula.filled[f] || legible[f] <= 0.01) continue + ctx.globalAlpha = NEBULA_FILL_ALPHA * legible[f] + strokeOrArc(paths[f], false) + } + for (var i = 0; i < paths.length; i++) { + if (legible[i] <= 0.01) continue + var loud = nebula.prominence[i] + ctx.globalAlpha = NEBULA_LINE_ALPHA * (0.30 + 0.70 * loud) * legible[i] + ctx.lineWidth = hairline() * (0.9 + 1.5 * loud) + strokeOrArc(paths[i], true) + } + + // The group the push is aimed at, drawn last so it is on top and + // regardless of whether its outline was legible enough to draw at + // all: a one-content group is exactly the case where the reader most + // needs to be told what they are about to enter. + if (intent) { + for (var m = 0; m < nebula.contours.length; m++) { + if (nebula.contours[m].tag !== intent) continue + ctx.globalAlpha = 0.35 + 0.55 * intentStrength + ctx.strokeStyle = colors.focusRing + ctx.lineWidth = hairline() * (1.5 + 2.5 * intentStrength) + // The outline flows while the push is on it. The weight + // already answers "how hard"; the flow answers "at what", + // which is the question a reader mid-gesture is asking, and + // a moving edge separates this outline from the still ones + // around it faster than a thicker one does. + // + // Divided by camera.scale because this runs inside the world + // transform, the same correction drawDrag makes so its dash + // stays a constant size on screen at any zoom. + ctx.setLineDash([INTENT_DASH[0] / camera.scale, INTENT_DASH[1] / camera.scale]) + ctx.lineDashOffset = + -motionPhase(INTENT_FLOW_MS) * (INTENT_DASH[0] + INTENT_DASH[1]) / camera.scale + strokeOrArc(paths[m], true) + ctx.setLineDash([]) + break + } + } + ctx.restore() + } + + /** A contour, as its own circle where it is one, else as its path. */ + function strokeOrArc(entry, stroke) { + if (entry.circle) { + ctx.beginPath() + ctx.arc(entry.circle.x, entry.circle.y, entry.circle.r, 0, Math.PI * 2) + if (stroke) ctx.stroke() + else ctx.fill() + return + } + if (stroke) ctx.stroke(entry.path) + else ctx.fill(entry.path) + } + + /** + * Where every mark is drawn THIS frame, under its content's key. + * + * One list, read by the dots, the cards and the hit test alike, so they + * cannot disagree about where a content is. They used to: the draw used + * the interpolated positions during a level change while the hit test + * read the settled ones, so mid-transition a tap landed on the wrong + * content. + * + * During a level change a content present at both levels slides from + * where it was to where it belongs; one only in the new level fades in, + * one only in the old fades out where it stood. + */ + function marksThisFrame() { + if (!state.nebula) return [] + if (!(morph && bloom)) return state.nebula.points + if (marksFrame && marksFrame.t === bloom.t) return marksFrame.marks + + var progress = Layout.smoothstep(0, 1, bloom.t) + var map = morph.map + var wasByKey = {} + morph.marks.forEach(function (mark) { + if (wasByKey[mark.key] === undefined) wasByKey[mark.key] = mark + }) + + var out = [] + var nowKeys = {} + state.nebula.points.forEach(function (mark) { + nowKeys[mark.key] = true + var was = wasByKey[mark.key] + if (!was) { + out.push({ key: mark.key, x: mark.x, y: mark.y, + inBand: mark.inBand, group: mark.group, alpha: progress }) + return + } + var fromX = was.x * map.scale + map.x + var fromY = was.y * map.scale + map.y + out.push({ + key: mark.key, + x: fromX + (mark.x - fromX) * progress, + y: fromY + (mark.y - fromY) * progress, + inBand: mark.inBand, group: mark.group, alpha: 1, + }) + }) + morph.marks.forEach(function (mark) { + if (nowKeys[mark.key]) return + out.push({ + key: mark.key, + x: mark.x * map.scale + map.x, + y: mark.y * map.scale + map.y, + inBand: mark.inBand, group: -1, alpha: 1 - progress, leaving: true, + }) + }) + marksFrame = { t: bloom.t, marks: out } + return out + } + + /** + * The marks, while they are still too small to be anything but dots. + * + * One path, one fill. Four hundred separate arc calls cost more than the + * whole rest of the frame, and at this size individual styling would not + * be visible anyway. Above RING_PX the marks become cards, drawn in + * screen space by drawNebulaCards -- text is a fixed size and cannot be + * drawn inside the world transform. + */ + /** + * How solid a content mark is at this size. Both passes read this, so + * the batched dots and the discs that replace them agree at RING_PX by + * construction rather than by two matching literals. + */ + function starAlpha(screenR) { + return STAR_ALPHA_MIN + (STAR_ALPHA_MAX - STAR_ALPHA_MIN) + * Layout.smoothstep(STAR_MIN_PX, RING_PX, screenR) + } + + /** + * A slow drift for a mark drawn as a speck, in CSS px. + * + * The only decoration here that says nothing. Everything else that moves + * is an instrument -- the pulse says which way the card points, the ring + * says what is being read, the flowing outline says what a push is aimed + * at -- and this is the texture of the field itself, so that a map with + * nothing selected is not a still photograph. + * + * How far it may drift, and why it is not confined to the specks. + * + * Marks are at least CONTENT_PITCH apart and a card's diagonal is + * exactly CONTENT_PITCH, so two cards can at worst touch at a corner -- + * that is what lets a title be drawn inside its mark with no collision + * test at all. Drifting by A leaves them 2A closer, which sounds like it + * spends that guarantee, and the first version of this confined the + * drift to below RING_PX for exactly that reason. + * + * It was the wrong reading. The guarantee that matters is about TITLES, + * and a title is inset CARD_PAD (8 px) from its card's edge, so two + * titles are 16 px apart when their cards touch. They cannot meet until + * A reaches 8 px. At FLOAT_PX the drift spends a third of that margin + * and the cards themselves never visibly overlap. + * + * Measured cost of the first version: at the root's fit scale, contentPx + * is about 4.5, where the fade to RING_PX left an amplitude of 0.2 px. + * The drift was placed where it was safe rather than where the reader + * was, and it was invisible in the view the map opens on. + * + * Read by every pass that turns a mark into a position -- through + * markScreen() -- and by pickStar, because markShape()'s rule is that + * what is visible is what is touchable. + */ + function markFloat(mark) { + return fieldAt(mark.x, mark.y) + } + + var fieldPhase = { serial: -1, a: 0, b: 0, c: 0, d: 0 } + + /** + * The field's four phases, advanced once a FRAME rather than once per + * sample. + * + * They are constant within a frame, and the field is now read by every + * mark AND every contour vertex -- 12,370 of them on the root. Computing + * them inline cost four performance.now() calls per sample, about fifty + * thousand a frame, and took the frame from 3.7 ms to 13.9 ms against a + * 16.7 ms budget. The arithmetic was never the expense; asking the clock + * was. + */ + function fieldPhases() { + if (fieldPhase.serial !== frameSerial) { + fieldPhase.serial = frameSerial + fieldPhase.a = motionPhase(FLOAT_MS) * TAU + fieldPhase.b = motionPhase(FLOAT_MS * 1.618) * TAU + fieldPhase.c = motionPhase(FLOAT_MS * 0.773) * TAU + fieldPhase.d = motionPhase(FLOAT_MS * 1.317) * TAU + } + return fieldPhase + } + + /** + * The drift field at a world point, in CSS px. + * + * One function, sampled by everything that moves: the marks, the vertices + * of the outlines around them, and the anchors their names sit on. That + * is what makes the outline follow its contents rather than merely move + * near them -- an isoline of a warped field is very nearly the warp of + * the isoline, and the error is the field's gradient over one kernel + * radius. Measured on the root at contentPx 45: the warped outline sits + * within 14.5 px of the true isoline of the warped marks, against a + * 180.8 px kernel margin, and no mark left its own outline in 830 checks. + */ + function fieldAt(x, y) { + if (reducedMotion) return ZERO_DRIFT + // A FIELD, phased by where a mark is rather than by which mark it is. + // Neighbours therefore drift almost together, and what changes + // between two of them is the field's gradient over the distance + // separating them -- at FLOAT_WAVE that is about a sixth of the + // amplitude across one CONTENT_PITCH, so their separation barely + // moves however far the field swings. + // + // Independent per-mark phases were tried first and are what forced + // the amplitude down: two neighbours in opposite phase close by 2A, + // so A had to stay well under the 8 px where titles would meet. A + // coherent field lifts that ceiling almost entirely, and it is also + // the difference between a field breathing and four hundred separate + // things jittering. + // Each term takes ONE phase, with a coefficient of exactly 1. + // + // motionPhase is a sawtooth: it steps from 1 back to 0. sin and cos + // are TAU-periodic, so a phase used as `sin(p + k)` crosses that step + // without a seam -- but `sin(0.6p + k)` does not, because 0.6*TAU is + // not a whole turn. Scaling a wrapped phase was how the first version + // of this was written, and it tore twice a cycle. Four periods, + // mutually incommensurate, give the same unrepeating drift with no + // term that can tear. + var ph = fieldPhases() + var a = ph.a, b = ph.b, c = ph.c, d = ph.d + var u = x / FLOAT_WAVE, v = y / FLOAT_WAVE + // A second, finer octave. The coarse one alone made neighbours move + // as one: over a CONTENT_PITCH gap its phase advances by only + // 2.6/15 of a radian, so two marks side by side differed by 17% of + // the amplitude and the field read as a few large slabs sliding. + // + // Shortening the coarse wavelength instead would have bought detail + // by spending the coherence the amplitude rests on. An octave adds + // the detail beside it: FLOAT_OCTAVE times the frequency at a third + // the weight, which roughly doubles what separates two neighbours + // while leaving the broad drift they share intact. + var p = u * FLOAT_OCTAVE, q = v * FLOAT_OCTAVE + var amp = floatAmplitude() + return { + x: (Math.sin(a + u + v * 0.7) + Math.sin(c + v * 1.3)) * 0.375 * amp + + Math.sin(b + p - q * 0.6) * 0.25 * amp, + y: (Math.cos(b + v - u * 0.8) + Math.cos(d + u * 1.1)) * 0.375 * amp + + Math.cos(c + q + p * 0.9) * 0.25 * amp, + } + } + + /** + * How far the field swings, in CSS px. + * + * Not a constant, and not proportional either. A fixed number of pixels + * is 83% of a speck and 1.3% of a full-sized card, so the drift read as + * strong when zoomed out and vanished when zoomed in -- which was a side + * effect of picking the simplest safe value, not a decision. Strict + * proportion is the other extreme: matching the speck's share would want + * about 28 px at contentPx 45. + * + * The square root is the usual compromise between "the same movement" + * and "the same relative movement", and it keeps the share within one + * order of magnitude across the whole zoom range (26% of a speck, 8% of + * a card at 45, 5% at the ceiling) instead of two. + */ + function floatAmplitude() { + return Math.min(FLOAT_MAX_PX, FLOAT_K * Math.sqrt(contentPx())) + } + + /** + * Where a mark is DRAWN, in CSS px: its layout position plus its drift. + * + * One function, so the dot, the card, the reading ring, the connector + * and the hit test cannot disagree about where a content is -- the same + * discipline markShape() and marksThisFrame() keep, and for the same + * reason: they have disagreed before, and it read as the map breaking. + */ + function markScreen(mark) { + var screen = worldToScreen(mark.x, mark.y) + var drift = markFloat(mark) + screen.x += drift.x + screen.y += drift.y + return screen + } + + function drawNebulaStars(colors) { + var screenR = contentPx() + if (screenR < STAR_MIN_PX) return // the outlines carry the density + if (screenR >= RING_PX) return // the per-mark pass takes over + + var marks = marksThisFrame() + if (marks.length === 0) return + var worldR = Layout.CONTENT_R + var batch = new Path2D() + marks.forEach(function (mark) { + // markFloat answers in CSS px; this path is built in world units, + // so the drift is divided by the scale to stay the same size on + // screen at any zoom -- the correction drawDrag makes for its + // dash, for the same reason. + var drift = markFloat(mark) + var x = mark.x + drift.x / camera.scale + var y = mark.y + drift.y / camera.scale + batch.moveTo(x + worldR, y) + batch.arc(x, y, worldR, 0, TAU) + }) + ctx.save() + ctx.globalAlpha = starAlpha(screenR) + // The same two colours a big mark wears, so a content's colour does + // not depend on how far away the camera is. A mark still LOOKS + // darker when it is tiny, but that is geometry rather than a second + // palette: a 1 px edge is 100% of a 1.5 px dot's area, 40% of a 5 px + // one and 10% of a 20 px one. + ctx.fillStyle = colors.contentDisc + ctx.fill(batch) + ctx.strokeStyle = colors.contentEdge + ctx.lineWidth = hairline() + ctx.stroke(batch) + ctx.restore() + } + + /** + * The content being read, stroked again in its own colour. + * + * --tagmap-reading is defined as "the content you are READING -- the + * detail card and the lines tying it to every place that content + * appears", but only the lines ever used it: every mark was stroked + * contentEdge, #808080 in both themes, whether or not it was the one the + * card is about. The reader could see WHERE the thing they were reading + * sits and not see WHICH thing it was. + * + * A separate pass rather than a branch inside the two mark passes, for + * the same reason drawNebula re-strokes the group a push is aimed at + * after its own loop: the dot pass batches every mark into ONE Path2D + * and fills it once, so per-mark styling there is not a branch, it is a + * split batch. Screen space and markShape(), so a single pass covers + * both regimes -- the shape interpolates disc to card, and a mark too + * small to hold a title is exactly when finding it matters most. + */ + function drawReadingMarks(colors) { + if (!infoTarget || !state.nebula) return + var shape = markShape() + var w = shape.w, h = shape.h + var marks = marksThisFrame() + ctx.save() + ctx.strokeStyle = colors.reading + // Heavier than the 1 px every other mark gets: at a hairline the hue + // alone carries it, and the hue is the one thing a reader with a + // colour deficiency may not have. + ctx.lineWidth = READING_EDGE_PX + // A dashed edge that turns, slowly: a lock on the thing being read, + // the same way the connector's pulse is a lock on the way to it. The + // dash pattern is fixed and only the offset moves, so the ring keeps + // its weight -- nothing here pulses, because a mark that throbs + // beside body text is an alert, and this is a place marker. + // Wrapped on the DASH cycle, not on the perimeter: the pattern + // repeats every dash+gap, so an offset that returns to 0 after + // exactly one of those is seamless, while one that runs a whole + // perimeter tears unless the perimeter happens to be a multiple of + // it -- which for a rounded rectangle that changes size with zoom it + // never is. The ring still turns; it just cannot say how far round + // it has been, which nothing needs it to. + var dashCycle = READING_DASH[0] + READING_DASH[1] + ctx.setLineDash(READING_DASH) + ctx.lineDashOffset = -motionPhase(READING_DASH_MS) * dashCycle + for (var i = 0; i < marks.length; i++) { + // A mark on its way out is not what you are reading, the same + // rule pickStar and drawInfoLinks apply. + if (marks[i].key !== infoTarget.key || marks[i].leaving) continue + var screen = markScreen(marks[i]) + if (!nearViewport(screen, w, h)) continue + ctx.globalAlpha = marks[i].alpha === undefined ? 1 : marks[i].alpha + roundedRect(ctx, screen.x - w / 2, screen.y - h / 2, w, h, shape.corner) + ctx.stroke() + } + ctx.setLineDash([]) + ctx.restore() + } + + /** + * The detail card, tied to every mark of the content it is showing. + * + * A content is drawn once per group it belongs to, so one content can be + * in three places at once -- that duplication is what keeps the group + * outlines from crossing, and the layout module says three times over + * that identity lives in the `key` "so the instances of one content can + * be tied together when the reader asks rather than always". This is the + * reader asking. + * + * Positions come from marksThisFrame(), the same list the dots, the + * cards and the hit test read, so a line cannot point somewhere a mark + * is not. Instances the camera is not showing are not dropped: the line + * stops at the viewport edge with a wedge, which is the only way the + * reader learns the content is also over there. + * + * Both ends stop on an EDGE -- the mark's drawn shape at one end, the + * detail card's rectangle at the other -- so the line touches what it + * connects instead of sliding underneath it. An instance the camera is + * not showing gets its line run off the viewport edge, which says + * "further that way" without adding a glyph to say it. + * + * Screen space, because one end of every line is a DOM rectangle. + */ + function drawInfoLinks(colors) { + linkEnds.length = 0 + if (!infoTarget || !state.nebula) return + var card = elements.infoCard + if (!card || !card.classList.contains("visible")) return + var box = card.getBoundingClientRect() + if (!(box.width > 0)) return + var anchor = { x: box.left + box.width / 2, y: box.top + box.height / 2 } + var cardRect = { x: box.left, y: box.top, w: box.width, h: box.height } + var viewport = { x: 0, y: 0, w: cssW, h: cssH } + + var marks = marksThisFrame().filter(function (mark) { + return mark.key === infoTarget.key && !mark.leaving + }) + if (marks.length === 0) return + + // The mark's own drawn shape, so a line can stop at its edge the way + // it stops at the card's. This clip used to be load-bearing for a + // second reason -- the cards were drawn after this pass, so anything + // reaching a grown mark's centre ended up under it. That is no longer + // true: the pass now runs after the cards. The clip stays for the + // reason it was named after, which is the one that never depended on + // draw order: a line that ran to the centre would cross the title of + // the very mark it is pointing at. + var shape = markShape() + + ctx.save() + ctx.strokeStyle = colors.reading + ctx.fillStyle = colors.reading + ctx.lineWidth = LINK_WIDTH_PX + ctx.lineJoin = "round" + for (var i = 0; i < marks.length; i++) { + var screen = markScreen(marks[i]) + // Off screen: stop at the edge and say which way it lies. + var clamped = Layout.clampToRect(anchor.x, anchor.y, screen.x, screen.y, viewport) + var far = clamped || Layout.clipToRect(anchor.x, anchor.y, screen.x, screen.y, { + x: screen.x - shape.w / 2, y: screen.y - shape.h / 2, + w: shape.w, h: shape.h, + }) || screen + + // The near end stops ON the card, not under it. + // + // This clip was here once, removed, and is back, and the reason + // is worth keeping because it is the same reason both times: the + // card USED to be opaque, so the browser covered the tail and + // clipping bought nothing while costing lines -- an instance + // behind the card had its whole segment inside the rectangle and + // got dropped (measured: 3 lines for 4 instances at k=26). + // + // The card is translucent now, so the tail would be visible: a + // fan of lines converging on a point under the text. And the + // cost that made clipping a bad trade has gone with it, because + // an instance behind the card is now visible THROUGH it -- the + // line was standing in for a mark the reader could not see, and + // the mark speaks for itself. + var near = Layout.clipToRect(far.x, far.y, anchor.x, anchor.y, cardRect) + if (near) { + // A brightening that travels from the card TOWARD the + // content, so the line says which end is the question and + // which is the answer. A uniform dash would be cheaper and + // says nothing about direction. + // + // Two strokes rather than a gradient: a gradient wants its + // stops as colour strings with alpha, and the palette hands + // out whatever the stylesheet says -- parsing it here would + // put a colour parser in the draw path to save one stroke of + // a handful of lines. + ctx.globalAlpha = LINK_DIM_ALPHA + ctx.beginPath() + ctx.moveTo(far.x, far.y) + ctx.lineTo(near.x, near.y) + ctx.stroke() + + // at(0) is the card edge, at(1) is the mark. A phase that + // counts UP therefore walks the head from the card out to + // the content: the card asks, and the line goes and points. + var head = motionPhase(LINK_PULSE_MS) + var at = function (t) { + return { x: near.x + (far.x - near.x) * t, y: near.y + (far.y - near.y) * t } + } + + // The band WRAPS rather than restarting. Its tip leaves at + // the mark while its tail is still short of it, and the part + // that has left re-enters at the card in the same instant -- + // so it is drawn as two pieces whenever it straddles an end, + // and the amount of bright line never changes. + // + // An earlier version faded the pulse out at both ends so the + // restart could not be seen. That hid the seam instead of + // removing it, and cost the line its steady brightness for + // most of every trip. + var a0 = head - LINK_PULSE_LEN, b0 = head + LINK_PULSE_LEN + var spans = a0 < 0 ? [[a0 + 1, 1], [0, b0]] + : b0 > 1 ? [[a0, 1], [0, b0 - 1]] + : [[a0, b0]] + ctx.globalAlpha = 1 + for (var sp = 0; sp < spans.length; sp++) { + var from = at(spans[sp][0]), to = at(spans[sp][1]) + ctx.beginPath() + ctx.moveTo(from.x, from.y) + ctx.lineTo(to.x, to.y) + ctx.stroke() + } + } + linkEnds.push({ + x: Math.round(far.x), y: Math.round(far.y), + offScreen: !!clamped, behindCard: !near, group: marks[i].group, + }) + } + ctx.restore() + } + + /** + * A content, as the card it grows into. + * + * The title and summary go INSIDE the mark, not beside it. Beside it they + * collide, and the collision test was both expensive and arbitrary: + * measured on /Arduino, 70-77 marks had a title to show and only 7-12 got + * the space, chosen by nothing but distance from the screen centre. + * Inside, collision is impossible and every content that can be named is. + * + * The shape interpolates from the disc it was: at Layout.cardFor().round + * = 1 the width, height and corner radius all agree on a circle, so the + * handover from the batched dots at RING_PX is invisible. + */ + /** + * The shape a mark has on screen right now, in CSS px. + * + * A disc at round = 1 -- width, height and corner radius all agree on a + * circle -- and the card at round = 0, interpolated between. Read by the + * draw AND by the hit test, so what is visible is what is touchable. + */ + /** + * Is a mark near enough to the viewport that its card gets drawn? + * + * The card pass and ensureBodies() share this, so "drawn" and "asked + * about" are the same set. They used to differ: cards were drawn out to + * a card's width past the edge while bodies were only asked for within + * 60 px, so the marks in the gap were drawn and never named. Measured 2 + * of the 24 on screen in the ordinary /Arduino view, blank until the + * camera moved -- the same symptom as the population bug and a wholly + * separate cause. This is markShape()'s rule applied to a second pair of + * passes: one predicate, so they cannot drift apart. + */ + /** + * Does a circle put any ink on screen? Used to decide what COUNTS as + * wanting a name, not what gets one. + * + * The nearest point of the viewport to the centre, then one distance + * test -- a group whose centre is far outside can still cross the + * viewport with its rim, and that group is visible and unnamed. + */ + function circleOnScreen(x, y, r) { + var nx = Math.max(0, Math.min(x, cssW)) + var ny = Math.max(0, Math.min(y, cssH)) + return (x - nx) * (x - nx) + (y - ny) * (y - ny) <= r * r + } + + function nearViewport(screen, w, h) { + return Layout.nearRect(screen.x, screen.y, w, h, + { x: 0, y: 0, w: cssW, h: cssH }) + } + + function markShape() { + var screenR = contentPx() + var card = Layout.cardFor(screenR) + var disc = 2 * Layout.CONTENT_R * camera.scale + var grown = 1 - card.round + var w = disc + (card.w - disc) * grown + var h = disc + (card.h - disc) * grown + return { + card: card, + screenR: screenR, + grown: grown, + w: w, + h: h, + corner: (Math.min(w, h) / 2) * card.round + CARD_CORNER_PX * grown, + } + } + + /** + * Is (px, py) inside a rounded rectangle of w x h centred on (cx, cy)? + * + * Exact for both ends of the morph: at corner = w/2 = h/2 this is a + * circle test, at corner = 0 a rectangle test. + */ + function insideRounded(px, py, cx, cy, w, h, corner) { + var dx = Math.abs(px - cx), dy = Math.abs(py - cy) + var hx = w / 2, hy = h / 2 + if (dx > hx || dy > hy) return false + var r = Math.min(corner, hx, hy) + var inx = hx - r, iny = hy - r + if (dx <= inx || dy <= iny) return true + return (dx - inx) * (dx - inx) + (dy - iny) * (dy - iny) <= r * r + } + + function drawNebulaCards(colors) { + cardRects.length = 0 + var screenR = contentPx() + var marks = screenR < RING_PX ? [] : marksThisFrame() + if (marks.length === 0) { + // Said plainly rather than left stale: a reader of the snapshot + // must not see the last card tier's counts while dots are drawn. + state.labels = { contentPx: Math.round(screenR * 100) / 100, tier: "dots" } + return + } + + var shape = markShape() + var card = shape.card + var grown = shape.grown + var w = shape.w, h = shape.h, corner = shape.corner + // Above RING_PX this is 1; it is here so the two passes multiply by + // the same number and agree exactly at the boundary between them. + var markAlpha = starAlpha(screenR) + var named = 0, onScreen = 0, withBody = 0 + + ctx.save() + ctx.textBaseline = "middle" + ctx.lineWidth = 1 + for (var i = 0; i < marks.length; i++) { + var mark = marks[i] + var screen = markScreen(mark) + if (!nearViewport(screen, w, h)) continue + onScreen++ + var body = bodies[mark.key] + if (body) withBody++ + + var x = screen.x - w / 2, y = screen.y - h / 2 + var alpha = mark.alpha === undefined ? 1 : mark.alpha + ctx.globalAlpha = alpha + roundedRect(ctx, x, y, w, h, corner) + + // Every content is drawn alike, in two colours that split the + // two jobs a mark has to do at once: + // + // the EDGE says "here is a mark" -- 3.2:1 against the map + // the FILL says "text goes here" -- 7.94:1 for 11 px type + // + // One flat colour cannot do both. Measured: a fill light enough + // to read type on tops out at 1.39:1 against the ground, and the + // lightest fill that reaches 3:1 on its own is about #808080, + // on which the 12 px line falls to 2.07:1. Letting the edge + // carry the shape frees the fill to be light, which is also the + // shape the site's own content cards already have. + // + // Only the FILL changes with size, and only where it has to: a + // circle holds no text so it can be a grey chip, a card has to + // be something type reads on. Tied to `round` rather than to the + // size, so the crossing is already done wherever text actually + // appears -- #ccd4db by the first title, plain paper before the + // excerpt. The edge never changes, and neither does the disc + // colour, so zooming out does not repaint the contents. + ctx.globalAlpha = markAlpha * alpha + ctx.fillStyle = colors.contentPaper + ctx.fill() + if (card.round > 0) { + ctx.globalAlpha = markAlpha * alpha * card.round + ctx.fillStyle = colors.contentDisc + ctx.fill() + } + ctx.globalAlpha = markAlpha * alpha + ctx.strokeStyle = colors.contentEdge + ctx.lineWidth = 1 + ctx.stroke() + + // Only a card that holds text blocks a group name. A disc does + // not: the group names sat beside discs happily before, and + // blocking on them would starve the names the level needs. + if (card.showTitle) cardRects.push({ x: x, y: y, w: w, h: h }) + + if (card.showTitle && body) { + if (drawCardText(colors, card, body, x, y, w, h)) named++ + } + } + ctx.restore() + ctx.globalAlpha = 1 + + // Why a content is or is not named, as numbers. With the collision + // test gone, "named" should equal "on screen and its body has + // arrived" -- anything less is a defect, not a budget. + // + // But that equality is NOT the measure of whether the map can name + // its contents, and reading it as one hid a real bug for a while: its + // denominator is "bodies that arrived", so a mark whose body can + // never arrive is not counted as a failure -- it is not counted at + // all. `anonymous` uses the denominator that cannot be gamed: every + // mark this pass DREW. Settled and above the title tier it must be + // 0. It may be positive for a bloom's length, when marks on their + // way out are still drawn and were deliberately not asked about; a + // figure that STAYS positive means either the manifest and the + // population disagree, or something is drawn that is never asked + // about. Both look like a blank card, and both used to happen. + state.labels = { + contentPx: Math.round(screenR * 100) / 100, + cardPx: Math.round(w) + "x" + Math.round(h), + round: Math.round(card.round * 100) / 100, + lines: card.lines, + marks: marks.length, + onScreen: onScreen, + withBodyAndOnScreen: withBody, + named: named, + anonymous: onScreen - withBody, + } + } + + /** + * The type inside a card: title, then where it lives, then the excerpt. + * + * Dropped from the bottom as the room runs out, so the most identifying + * line survives longest. Nothing is cached -- measured, wrapping 24 + * summaries costs 0.19 ms a frame against a 16.7 ms budget, and at the + * card scale only about two dozen marks are on screen at all. + */ + function drawCardText(colors, card, body, x, y, w, h) { + var left = x + Layout.CARD_PAD + var width = card.usableW + if (width < 24) return false + var lineY = y + Layout.CARD_PAD + Layout.CARD_LINE_PX / 2 + var bottom = y + h - Layout.CARD_PAD + var drew = false + + ctx.font = "600 " + Layout.CARD_TITLE_PX + "px system-ui, sans-serif" + ctx.fillStyle = colors.ink + var titleLines = card.titleLines > 1 + ? wrapText(ctx, body.title, width, card.titleLines) + : [ellipsize(body.title, width, ctx)] + for (var t = 0; t < titleLines.length; t++) { + if (lineY + Layout.CARD_LINE_PX / 2 > bottom) break + ctx.fillText(titleLines[t], left, lineY) + lineY += Layout.CARD_LINE_PX + drew = true + } + + if (card.showParent && body.parentTitle) { + ctx.font = Layout.CARD_BODY_PX + "px system-ui, sans-serif" + ctx.fillStyle = colors.muted + if (lineY + Layout.CARD_LINE_PX / 2 <= bottom) { + ctx.fillText(ellipsize(body.parentTitle, width, ctx), left, lineY) + lineY += Layout.CARD_LINE_PX + } + } + + if (card.summaryLines > 0 && body.summary) { + if (body._plain === undefined) body._plain = stripHtml(body.summary) + if (body._plain) { + ctx.font = Layout.CARD_BODY_PX + "px system-ui, sans-serif" + ctx.fillStyle = colors.inkSecondary + var lines = wrapText(ctx, body._plain, width, card.summaryLines) + for (var i = 0; i < lines.length; i++) { + if (lineY + Layout.CARD_LINE_PX / 2 > bottom) break + ctx.fillText(lines[i], left, lineY) + lineY += Layout.CARD_LINE_PX + } + } + } + return drew + } + + /** + * The level you are in: a faint wash over its interior, and its outline. + * + * The wash is not decoration. Zoomed in, the boundary line is off screen + * and the wash is the ONLY thing saying you are inside this level rather + * than out among the ancestors -- removing it was tried and took that + * away. + * + * It does cost the map some colour, and the amount is known: the wash + * sits UNDER the tag wash, and the two together decide what the inside + * of a nebula looks like. Amber under blue partly cancels (chroma 6 with + * the site amber, 3 with the old #eda100) where an unwashed nebula would + * read at 16. That is the price of the signal, paid knowingly, and it is + * why the wash is 7% rather than more. + */ + function drawContainer(colors) { + if (state.segments.length === 0) return + var path = containerPath() + if (!path) return + ctx.save() + ctx.globalAlpha = 0.07 + ctx.fillStyle = colors.focus + ctx.fill(path) + ctx.globalAlpha = 1 + ctx.strokeStyle = colors.focusRing + ctx.lineWidth = hairline() * 2.5 + ctx.stroke(path) + ctx.restore() + } + + /** + * The current level's boundary: how far its contents reach. + * + * Two things it has to be, and the first version was neither. + * + * It has to CONTAIN the contents. The outline the reader clicked + * describes the PARENT's knowledge of that group -- its `count` marks + * inside a territory of radius r -- while the level holds `reach` marks + * spread over about 2.2r. Carrying that outline through left 51 of 85 + * marks outside their own boundary. + * + * And it must not become a circle at the moment of entering, which is a + * shape discontinuity the camera cannot absorb. + * + * So: a radial hull of this level's own marks, blended angle by angle + * with the hull of the outline that was clicked. At t=0 it is the shape + * the reader touched; at t=1 it encloses everything here. + */ + /** + * The boundary's radii for this frame, mid-morph included. Split out of + * containerPath so the hit test can read the same numbers the stroke + * does, instead of a circle that overshoots them by up to 40%. + */ + function containerRadii() { + var shapes = sceneShapes() + if (!shapes || !shapes.boundary) return null + if (state.enteredHull && bloom) { + return Layout.blendHulls(state.enteredHull, shapes.boundary, + Layout.smoothstep(0, 1, bloom.t)) + } + return shapes.boundary + } + + function containerPath() { + var radii = containerRadii() + return radii ? ringsToPath([Layout.hullRing(radii, 0, 0)]) : null + } + + /** This level's cached marks and outlines. */ + function sceneShapes() { + if (!state.data) return null + return levelContours(state.data, levelLayout(state.data)) + } + + /** + * Direct contents, as dots on the inside of the boundary. A content that + * matched by name similarity rather than by a tag is drawn hollow: it is + * not tagged with what was selected, and saying so is the distinction the + * server-rendered view used to make with a "Suggested" badge. + */ + var ORIGIN_ONLY = [[0, 0]] + + /** + * Where a label may sit: its anchor, then two rings around it. + * + * Eight directions rather than four, so a name hemmed in on two sides + * still has somewhere to go, and the near ring first so it stays as + * close to the thing it names as it can. + */ + function nudgeLadder(step) { + var out = [[0, 0]] + for (var ring = 1; ring <= 2; ring++) { + var r = step * ring + for (var i = 0; i < 8; i++) { + var angle = i * Math.PI / 4 + out.push([Math.cos(angle) * r, Math.sin(angle) * r]) + } + } + return out + } + + function drawLabels(colors) { + var boxes = [] + // LABEL_MAX_BOXES says "labels per frame" and used to count `boxes`, + // which holds obstacles too. The chrome seeds about 3 and the cards + // seed one per titled mark -- measured, roughly 24 in a viewport -- + // so from contentPx 26 (where cardFor().showTitle turns on) some 27 + // of the 30 were spent before a single name was attempted. Measured + // at contentPx 40: 3 groups visible, 0 named, 106 refusals all from + // this one test. + // + // The obstacles still block placement; they just do not spend the + // allowance. The cost the allowance bounds is the collision scan, + // and that is already bounded by the viewport -- only the cards on + // screen are ever in the list. + var placed = 0 + labelHits.length = 0 + nameStats = { wanted: 0, placed: 0, tooSmall: 0, budget: 0, offScreen: 0, collided: 0 } + var container = containerRegion() + var contentRadiusPx = contentPx() + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.textAlign = "left" + ctx.textBaseline = "middle" + + // The floating chrome owns its rectangles: seeding them means a label + // can never land under the header or the breadcrumb. + chromeRects().forEach(function (rect) { boxes.push(rect) }) + // So do the cards. This IS a collision test, but a bounded one: at + // most LABEL_MAX_BOXES names against the couple of dozen cards a + // viewport holds, not every content against every other. The test + // the plan rejected was the all-pairs one, and that is exactly what + // moving the text inside the mark removed. + cardRects.forEach(function (rect) { boxes.push(rect) }) + + function place(lines, screenX, screenY, options) { + options = options || {} + if (placed >= LABEL_MAX_BOXES) { nameStats.budget++; return false } + // Off-screen labels used to consume the budget and starve the + // visible ones, so content titles never appeared at all. + // + // `clamp` is the exception, and only the group names pass it: a + // group you have zoomed INTO fills the screen while its anchor + // sits outside, so the one name the reader most needs is the one + // this test threw away. Measured at contentPx 60: 3 groups + // visible, 0 named, every refusal from here. Pulled to the edge + // instead, the way an off-screen connector end is clamped rather + // than dropped. The caller decides what "visible" means -- it + // only clamps for a circle that actually crosses the viewport, + // so a group genuinely off screen is still refused here. + if (!options.clamp + && (screenX < -80 || screenX > cssW + 80 || screenY < -60 || screenY > cssH + 60)) { + nameStats.offScreen++ + return false + } + var padding = options.boxed ? 6 : 3 + var lineHeight = options.lineHeight || 16 + var width = 0 + lines.forEach(function (line) { + ctx.font = line.font + width = Math.max(width, ctx.measureText(line.text).width) + }) + var w = width + padding * 2 + var h = lines.length * lineHeight + padding * 2 + // Now that the label's size is known, the clamp can keep the + // whole of it on screen rather than just its anchor point. + if (options.clamp) { + screenX = Math.max(w / 2 + 6, Math.min(screenX, cssW - w / 2 - 6)) + screenY = Math.max(h / 2 + 6, Math.min(screenY, cssH - h / 2 - 6)) + } + var nudges = options.nudges || ORIGIN_ONLY + var box = null + for (var n = 0; n < nudges.length && !box; n++) { + var tryX = screenX + nudges[n][0], tryY = screenY + nudges[n][1] + var candidate = { + x: options.center ? tryX - w / 2 : tryX + 10, + y: tryY - h / 2, + w: w, + h: h, + } + var free = true + for (var i = 0; i < boxes.length && free; i++) { + var other = boxes[i] + free = !(candidate.x < other.x + other.w + && candidate.x + candidate.w > other.x + && candidate.y < other.y + other.h + && candidate.y + candidate.h > other.y) + } + if (free) box = candidate + } + if (!box) { nameStats.collided++; return false } + boxes.push(box) + placed++ + // Recorded for the hit test, so what the reader can read is what + // they can press. Only labels that name a pickable thing pass a + // `hit`; the level's own caption and the drag hint do not. + if (options.hit) { + labelHits.push({ + x: box.x, y: box.y, w: box.w, h: box.h, + kind: options.hit.kind, tag: options.hit.tag, + node: options.hit.node, + }) + } + + if (options.boxed) { + ctx.globalAlpha = 0.94 + ctx.fillStyle = colors.surface + ctx.strokeStyle = colors.gridline + ctx.lineWidth = 1 + roundedRect(ctx, box.x, box.y, box.w, box.h, 5) + ctx.fill() + ctx.stroke() + ctx.globalAlpha = 1 + } + var textY = box.y + padding + lineHeight / 2 + lines.forEach(function (line) { + ctx.font = line.font + ctx.fillStyle = line.color + // Per-line alpha, so a level-of-detail tier can fade in + // beneath one that is already fully there. + ctx.globalAlpha = line.alpha === undefined ? 1 : line.alpha + ctx.fillText(line.text, box.x + padding, textY) + textY += lineHeight + }) + ctx.globalAlpha = 1 + return true + } + + // 1. The selection: what it is and how much it holds. Every number + // here names a stats field; none is derived from the loaded page. + if (container && state.data) { + var top = worldToScreen(container.x, container.y - container.r) + var stats = state.data.stats || {} + var caption = stats.totalContents + localize("件", " items") + if (typeof stats.directContents === "number" && stats.directContents > 0) { + caption += localize( + "・直下 " + stats.directContents + "件", + " · " + stats.directContents + " directly here" + ) + } + // The groups' counts do not add up to the total, because a + // content carrying two of them is in both. Say the distinct + // figure and say that they overlap, rather than leaving the + // reader to try the arithmetic and fail. + if (typeof stats.childContents === "number" && stats.childContents > 0) { + caption += localize( + "・子タグの中 " + stats.childContents + "件", + " · " + stats.childContents + " in child tags" + ) + if (state.members.length > 0) { + caption += localize( + "(" + state.members.length + "グループ・重複あり)", + " (" + state.members.length + " groups, overlapping)" + ) + } + } + var captionLines = [ + { text: segmentsLabel(state.segments), font: "700 20px system-ui, sans-serif", color: colors.ink }, + { text: caption, font: "600 13px system-ui, sans-serif", color: colors.inkSecondary }, + ] + // Why a drill-down can come back with more than the tag's own + // count promised: part of this set matched by name, not by tag. + if (typeof stats.explicitContents === "number" + && stats.explicitContents < stats.totalContents) { + var fuzzy = stats.totalContents - stats.explicitContents + captionLines.push({ + text: localize( + "うち " + fuzzy + "件は類似名タグから", + fuzzy + " matched by similar tag name" + ), + font: "11px system-ui, sans-serif", + color: colors.muted, + }) + } + place(captionLines, top.x, top.y - 32, { center: true, lineHeight: 22 }) + } + + // 2. The clouds gathered inside, biggest first. + state.members.slice().sort(function (a, b) { + var na = state.universe[a.tag], nb = state.universe[b.tag] + return (nb ? nb.r : 0) - (na ? na.r : 0) + }).forEach(function (entry) { + var node = state.universe[entry.tag] + if (!node) return + // The group's OWN anchor: the point where its field is + // strongest, which Layout.fieldArgmax guarantees is inside its + // contour. It was computed, stored and tested from the start and + // nothing read it -- the name sat on the packed slot centre + // instead, which for a lopsided territory is not even inside the + // shape being named. + var contour = state.nebula && state.nebula.contourByTag + ? state.nebula.contourByTag[entry.tag] : null + var at = (contour && contour.anchor) || node + // The same field the outline reads, so a name travels with the + // shape it names instead of hanging beside it. + var screen = worldToScreen(at.x, at.y) + var nd = fieldAt(at.x, at.y) + screen.x += nd.x + screen.y += nd.y + var visible = circleOnScreen( + worldToScreen(node.x, node.y).x, worldToScreen(node.x, node.y).y, + node.r * camera.scale) + // Only groups the reader can actually SEE count as wanting a + // name. Counting all of them instead made the rate meaningless: + // zoomed in, most of the root's 106 circles are genuinely off + // screen and not naming them is correct, so the denominator fell + // as fast as the numerator and the number said nothing. Measured + // that way, contentPx 60 reported 0 of 106 -- which reads as a + // total failure and is in fact the right answer. + if (visible) nameStats.wanted++ + // Below this the circle is too small for a name to be about + // anything the reader can see: at contentPx 5 it turns away the + // 40 single-content groups of the root, whose outlines are not + // drawn either (NEBULA_MIN_SCREEN_R is higher still). Measured + // from contentPx 8 upward it never fires. + if (node.r * camera.scale < LABEL_MIN_SCREEN_R) { nameStats.tooSmall++; return } + if (place([ + { text: node.tag, font: "600 14px system-ui, sans-serif", color: colors.ink }, + // "of these, N" -- a fact about the current set, matching the + // quantity this circle's size encodes here. + { + text: localize("このうち " + entry.count + "件", entry.count + " of these"), + font: "12px system-ui, sans-serif", + color: colors.inkSecondary, + }, + ], screen.x, screen.y, { + center: true, + lineHeight: 16, + nudges: nudgeLadder(Layout.CARD_H * contentRadiusPx * 0.75), + // Only a group that actually crosses the viewport earns the + // edge treatment. One that is wholly elsewhere is refused by + // the ordinary off-screen test, so zooming in does not line + // the borders with the names of things you cannot see. + clamp: visible, + hit: { kind: "cloud", tag: node.tag, node: node }, + })) nameStats.placed++ + }) + + // 2b. Name suggestions. The label IS the whole mark -- no outline + // is drawn for them at all -- so it has to carry the explanation: + // these came in by a similar NAME and may share no content with the + // selection whatsoever. + var suggestionNodes = [] + for (var suggestedTag in state.universe) { + if (state.universe[suggestedTag].kind === "suggestion") { + suggestionNodes.push(state.universe[suggestedTag]) + } + } + suggestionNodes.forEach(function (node) { + var screen = worldToScreen(node.x, node.y) + var lines = [ + { + text: localize("もしかして", "Similar name"), + font: "10px system-ui, sans-serif", + color: colors.muted, + }, + { text: node.tag, font: "600 13px system-ui, sans-serif", color: colors.ink }, + ] + // `count` is the truth about sharing, and it is often zero. Say + // which case this is rather than let the shape imply membership. + lines.push({ + text: node.count > 0 + ? localize("このうち " + node.count + "件", node.count + " of these") + : localize("共通なし", "nothing in common"), + font: "11px system-ui, sans-serif", + color: colors.muted, + }) + place(lines, screen.x, screen.y - node.r * camera.scale - 4, { + center: true, boxed: true, lineHeight: 14, + hit: { kind: "cloud", tag: node.tag, node: node }, + }) + }) + + // 4. The siblings you left behind, so the way out stays named. + // + // Which of them are drawn is ancestorChildShape's to say. This used + // to restate two of its four conditions as literals here and drop + // the other two, so a child too small to stroke could still be + // named -- the copy is what a shared function exists to prevent. + var others = [] + state.ancestors.forEach(function (ancestor) { + ancestor.children.forEach(function (child) { + var shape = ancestorChildShape(ancestor, child) + if (shape) others.push({ node: child, shape: shape }) + }) + }) + others.sort(function (a, b) { return b.node.r - a.node.r }) + others.forEach(function (entry) { + var node = entry.node + // A name needs more room than an outline does: this is about + // whether the circle is big enough to be worth naming, not about + // whether it is drawn, which is already settled above. + if (entry.shape.childR < LABEL_MIN_SCREEN_R) return + place([ + { text: node.tag, font: "600 13px system-ui, sans-serif", color: colors.muted }, + ], entry.shape.screen.x, entry.shape.screen.y, { + center: true, + lineHeight: 15, + nudges: nudgeLadder(Layout.CARD_H * contentRadiusPx * 0.75), + hit: { kind: "sibling", tag: node.tag, node: node }, + }) + }) + + // 5. The drop-target hint, last so it always gets its space: it is + // feedback on what your finger is doing right now. + if (dragHint) { + boxes.length = 0 + place([{ text: dragHint.text, font: "700 14px system-ui, sans-serif", color: colors.ink }], + dragHint.x, dragHint.y - 28, { center: true, boxed: true, lineHeight: 18 }) + dragHint = null + } + + ctx.textBaseline = "alphabetic" + } + + /** + * Screen rectangles the floating DOM chrome occupies. Measured per frame + * from the live elements, so a breadcrumb that wraps to two lines is + * accounted for without anything to keep in sync. + */ + function chromeRects() { + var rects = [] + var add = function (element) { + if (!element) return + var rect = element.getBoundingClientRect() + if (rect.width > 0 && rect.height > 0) { + rects.push({ x: rect.left, y: rect.top, w: rect.width, h: rect.height }) + } + } + add(document.getElementById("header")) + add(elements.breadcrumb) + // The detail card is the reading surface now, so a name may not sit + // under it. Only when it is open: hidden, getBoundingClientRect + // reports zeroes and `add` skips it anyway. + add(elements.infoCard) + return rects + } + + function stripHtml(html) { + var element = document.createElement("div") + element.innerHTML = html + return (element.textContent || "").replace(/\s+/g, " ").trim() + } + + function wrapText(context, text, maxWidth, maxLines) { + var lines = [] + var line = "" + var i = 0 + for (; i < text.length && lines.length < maxLines; i++) { + var candidate = line + text[i] + if (context.measureText(candidate).width > maxWidth && line !== "") { + lines.push(line) + line = text[i] + } else { + line = candidate + } + } + if (lines.length < maxLines && line !== "") { + lines.push(line) + return lines + } + // Ran out of lines with text still to come. Say so on the last one: + // an excerpt that just stops reads as a complete summary that + // happens to end mid-sentence. + if (lines.length > 0 && (line !== "" || i < text.length)) { + lines[lines.length - 1] = ellipsize( + lines[lines.length - 1] + line, maxWidth, context) + } + return lines + } + + /** + * The two groups melting into one, while the reader holds them together. + * + * Fusing A and B used to be a switch: you released, the URL became + * `A,B`, and a new picture appeared. What the reader had no way to see + * was what they were about to get. Here the union is drawn WHILE they + * hold it, as one soft body over both groups' marks -- which is the same + * shape the fused level will draw, because it is the same field over the + * same marks. + * + * The melt is the kernel: enlarging it until the two fields reach across + * the gap is exactly what "these are one body now" means, and it is the + * one parameter the density field already understands. Nothing else is + * animated, and no marks move. + */ + function drawFusionMelt(colors, sourceTag, targetTag) { + if (!state.nebula) return + var source = null, target = null + state.nebula.contours.forEach(function (contour) { + if (contour.tag === sourceTag) source = contour + if (contour.tag === targetTag) target = contour + }) + if (!source || !target) return + + // Contours are scene data, not frame data: a melt path costs a + // marching-squares pass, so it is built when the pair changes and + // reused while the reader holds it there. + // Length-prefixed, because a tag name can contain any character a + // separator might use -- including the comma that joins an OR group. + var cacheKey = sourceTag.length + ":" + sourceTag + targetTag + if (!meltPath || meltPath.key !== cacheKey) { + meltPath = { key: cacheKey, path: buildMeltPath(source, target) } + } + if (!meltPath.path) return + + ctx.globalAlpha = 0.14 + ctx.fillStyle = colors.focus + ctx.fill(meltPath.path) + ctx.globalAlpha = 0.8 + ctx.strokeStyle = colors.focusRing + ctx.lineWidth = hairline() * 2 + ctx.stroke(meltPath.path) + ctx.globalAlpha = 1 + } + + /** + * The single body two groups make, as one Path2D. + * + * The kernel that closes the gap is derived from the CLOSEST CROSS-GROUP + * pair, not from the distance between the territories: what has to merge + * is the marks. Measured across group sizes, one body appears at + * 0.78-0.88 of that distance -- which is the pairwise merge constant of + * the Wyvill kernel, 0.8625 -- so 0.95 clears it with margin at every + * size rather than being tuned to one. + */ + function buildMeltPath(source, target) { + var marks = state.nebula.points.filter(function (mark) { + return mark.group === source.index || mark.group === target.index + }) + if (marks.length === 0) return null + + var mine = marks.filter(function (m) { return m.group === source.index }) + var theirs = marks.filter(function (m) { return m.group === target.index }) + var crossGap = Infinity + for (var i = 0; i < mine.length; i++) { + for (var j = 0; j < theirs.length; j++) { + var d = Math.hypot(mine[i].x - theirs[j].x, mine[i].y - theirs[j].y) + if (d < crossGap) crossGap = d + } + } + if (!Number.isFinite(crossGap)) return null + + var anchors = state.nebula.anchors + var natural = Layout.kernelForPoints(marks, + Math.max(anchors[source.index].r, anchors[target.index].r)) + var bridging = Math.max(natural, crossGap * 0.95) + var outline = Layout.contour(marks.map(function (mark) { + return { x: mark.x, y: mark.y, r: bridging } + })) + if (outline.rings.length === 0) return null + + var path = new Path2D() + outline.rings.forEach(function (ring) { + if (ring.length < 2) return + path.moveTo(ring[0].x, ring[0].y) + for (var i = 1; i < ring.length; i++) path.lineTo(ring[i].x, ring[i].y) + path.closePath() + }) + return path + } + + function drawDrag(colors) { + if (!drag) return + // A sibling is not in state.universe -- it lives in state.ancestors + // -- so the tether has to read it from the drag itself. + var source = drag.sibling || state.universe[drag.tag] + if (!source) return + var world = screenToWorld(drag.x, drag.y) + + ctx.save() + ctx.strokeStyle = colors.accentRing + ctx.lineWidth = hairline() * 1.5 + ctx.setLineDash([6 / camera.scale, 4 / camera.scale]) + ctx.beginPath() + ctx.moveTo(source.x, source.y) + ctx.lineTo(world.x, world.y) + ctx.stroke() + ctx.setLineDash([]) + + ctx.globalAlpha = 0.5 + ctx.strokeStyle = colors.accentRing + ctx.beginPath() + ctx.arc(world.x, world.y, source.r * 0.35, 0, TAU) + ctx.stroke() + ctx.globalAlpha = 1 + + if (drag.target) { + var mark = (drag.target.kind === "out" || drag.target.kind === "absorb") + ? containerRegion() + : state.universe[drag.target.tag] + if (mark) { + ctx.strokeStyle = colors.focusRing + ctx.lineWidth = hairline() * 3 + ctx.beginPath() + ctx.arc(mark.x, mark.y, mark.r * 1.04, 0, TAU) + ctx.stroke() + } + if (drag.target.kind === "cloud") drawFusionMelt(colors, drag.tag, drag.target.tag) + } + ctx.restore() + + // The hint is screen text, so it belongs to the label pass, not here: + // drawn inside the world transform its size fought the zoom. + if (drag.target) { + dragHint = { + text: drag.target.kind === "out" + ? localize("ここから出る", "Leave this level") + : (drag.target.kind === "absorb" + // Widens THIS segment -- the frame it was dropped + // in. Matches the popup's join button. + ? localize("一緒に見る", "View together") + // A NEW level holding both. Matches the hint strip. + : localize("一緒に入る", "Enter together")), + x: drag.x, + y: drag.y, + } + } + } + + function roundedRect(context, x, y, w, h, r) { + context.beginPath() + context.moveTo(x + r, y) + context.arcTo(x + w, y, x + w, y + h, r) + context.arcTo(x + w, y + h, x, y + h, r) + context.arcTo(x, y + h, x, y, r) + context.arcTo(x, y, x + w, y, r) + context.closePath() + } + + function ellipsize(text, maxWidth, context) { + if (context.measureText(text).width <= maxWidth) return text + var result = text + while (result.length > 1 && context.measureText(result + "…").width > maxWidth) { + result = result.slice(0, -1) + } + return result + "…" + } + + // ---- hit testing ------------------------------------------------------ + + /** + * The content mark under the pointer. + * + * Tested against the shape the mark is DRAWN as -- the same rounded + * rectangle markShape() gives the renderer -- so a card's corners are + * live and a disc's are not. + * + * Scaled up whole if the mark is smaller than HIT_MIN_PX, so a 2 px dot + * is still tappable: the level of detail decides how much a mark SAYS, + * never whether it can be reached. + * + * Read from marksThisFrame(), not from the settled layout. Mid-transition + * those differ, and taking the settled one meant a tap during a level + * change landed on whichever content used to be there. + * + * Nearest centre wins. The shapes never overlap (the sunflower keeps the + * marks over a pitch apart and a card's diagonal is exactly that pitch), + * so only the enlarged minimum targets can, and there "nearest" is the + * one you meant. + */ + function pickStar(px, py) { + if (!state.nebula || contentPx() < STAR_MIN_PX) return null + var shape = markShape() + var grow = Math.max(1, HIT_MIN_PX / Math.min(shape.w, shape.h)) + var w = shape.w * grow, h = shape.h * grow, corner = shape.corner * grow + var best = null, bestDistance = Infinity + marksThisFrame().forEach(function (mark) { + // A mark on its way out is not a target: it is where a content + // WAS, and the reader is looking at where it is now. + if (mark.leaving) return + // The same position every draw pass uses, so what is visible + // stays what is touchable. + var screen = markScreen(mark) + var distance = Math.hypot(px - screen.x, py - screen.y) + if (distance >= bestDistance) return + if (!insideRounded(px, py, screen.x, screen.y, w, h, corner)) return + bestDistance = distance + best = mark + }) + if (!best) return null + // The body may not have arrived: the manifest carries identities, and + // the level of detail only asks for the marks big enough to show a + // title. A tap is a request for THIS one, so it is fetched now and + // the card opens when it lands. + return { key: best.key, item: bodies[best.key] || null, mark: best } + } + + /** + * Fetches one content's body, for a tap on a mark whose title has not + * been asked for yet. + */ + function fetchOneBody(key) { + if (bodyFetches[key] || bodyMisses[key]) return Promise.resolve(null) + // A tap is an explicit request, so it ignores the backoff -- but it + // still records one, so holding the pointer down cannot loop. + bodyFetches[key] = true + var askedFor = tagPathOf(state.segments) + var form = new FormData() + form.append("contentPath", state.contentPath) + form.append("tagPath", askedFor) + form.append("scope", "keys") + form.append("keys", key) + form.append("fields", "full") + return askService(form) + .then(function (body) { + delete bodyFetches[key] + // An error is not an empty answer. Without this the branch + // below would call a rejected request an inconsistency + // between the manifest and the population. + if (body.error) throw new Error(body.error) + // This used to be dropped whenever the reader navigated + // mid-flight, on the grounds that the tapped mark was gone + // and the card would describe something no longer drawn. The + // premise was wrong: a content keeps its key at every level, + // which is why marksThisFrame() can match one across a + // transition at all. What decides is whether the CONTENT is + // still on the map -- the same question syncInfoCardToLevel + // asks, so a tap and a level change cannot disagree about + // whether a card is about something visible. + // + // The tagPath the answer was resolved in still matters, but + // only for the membership badge, and forTagPath below carries + // that. Title, summary and URL are the same wherever the + // content is drawn. + var here = state.nebula ? state.nebula.points : [] + var present = false + for (var p = 0; p < here.length; p++) { + if (here[p].key === key) { present = true; break } + } + if (!present) return null + var items = (body.items || []).filter(function (item) { return item.key === key }) + // Two items for one key means the 48-bit key collided; show + // nothing rather than possibly the wrong article. That is + // permanent. Zero items is not: see ensureBodies. + if (items.length > 1) { bodyMisses[key] = true; return null } + if (items.length === 0) { + bodyBackoff[key] = Math.min(BODY_RETRY_MAX_MS, + (bodyBackoff[key] || BODY_RETRY_MS / 2) * 2) + bodyRetryAt[key] = performance.now() + bodyBackoff[key] + console.warn("TagMap: key " + key + " is in the manifest of " + + (askedFor || "(root)") + " but not in its population") + return null + } + items[0].forTagPath = askedFor + bodies[key] = items[0] + return items[0] + }) + .catch(function (error) { + delete bodyFetches[key] + bodyBackoff[key] = Math.min(BODY_RETRY_MAX_MS, + (bodyBackoff[key] || BODY_RETRY_MS / 2) * 2) + bodyRetryAt[key] = performance.now() + bodyBackoff[key] + console.warn("TagMap: body lookup failed", error) + return null + }) + } + + /** + * The groups a pick may return: this level's groups, minus what the + * boundary already represents. + * + * Not filtered by what was drawn, unlike pickSibling, and it does not + * need to be. The disc this reaches a group through is at most + * HIT_MIN_PX / 2 at its centre, so a group whose centre is off screen is + * already out of reach -- you can only press where you are looking, + * which is why adding a viewport test here was tried and changed + * nothing. Compare pickSibling, whose polygon DOES reach across the + * viewport and so has to be told. + * + * Measured over 36 zoom-and-position combinations: 2,104 answers, of + * which 387 named a group whose CENTRE is off screen -- all of them + * reached through the group's name, which drawLabels deliberately clamps + * to the viewport edge for a group whose rim is still visible -- and 0 + * named a group with no part of its circle on screen. + * + * The one gap left is a group inside the viewport whose outline the + * legibility cull skipped, and it is only ever a PRESS that reaches it. + * Measured on the root at its fit scale, where 68 of the 106 groups on + * screen are below NEBULA_MIN_SCREEN_R: a tap on their centres answered + * "content" 55 times and "name" 13 times and named the group itself + * zero times, because handleTap runs pickStar and pickLabel ahead of + * pickCloudCentre. pointerdown does not -- it asks pickCloud first -- + * so all 68 are grabbable as drag sources. That is the intended + * asymmetry: a drag is a GROUP gesture (contents are not drag sources), + * and the contents inside the group are drawn, so it is not a press on + * nothing. Zoom in one step and the gap is 0 of 57. + * + * Selected tags are drawn by drawContainer(), not as clouds, so they + * must not be pickable either: an invisible disc at the focus centre + * blocked panning inside your own selection and allowed A/A. + */ + function pickableClouds(excludeTag) { + var selected = selectedTags().reduce(function (set, tag) { + set[tag] = true + return set + }, {}) + var nodes = [] + for (var tag in state.universe) { + if (tag === excludeTag || selected[tag]) continue + nodes.push(state.universe[tag]) + } + // Smallest first: a small cloud sitting inside a big one is reachable. + nodes.sort(function (a, b) { return a.r - b.r }) + return nodes + } + + /** + * PRESSING and DROPPING want opposite things, so they get separate + * tests. + * + * Pressing must not reach past what is drawn -- a press on empty paper + * has to pan. Dropping wants to be easy to land on, and reaching too far + * costs nothing there because drawDrag rings the target and dragHint + * names the action BEFORE the finger lifts, so the reader can correct. + * + * This is the generous one, and it is the circle the single old pickCloud + * used for both jobs: `territoryRadius(reach)`, which is up to 2.1x the + * radius of the outline actually drawn. + */ + function pickDropTarget(px, py, excludeTag) { + var world = screenToWorld(px, py) + var nodes = pickableClouds(excludeTag) + for (var i = 0; i < nodes.length; i++) { + if (Math.hypot(world.x - nodes[i].x, world.y - nodes[i].y) <= nodes[i].r) return nodes[i] + } + return null + } + + /** + * A name drawn this frame, and what it names. The strictest possible + * parity: the reader pressed the very pixels of a word. + */ + function pickLabel(px, py, kind) { + for (var i = 0; i < labelHits.length; i++) { + var box = labelHits[i] + if (kind && box.kind !== kind) continue + if (px >= box.x && px <= box.x + box.w + && py >= box.y && py <= box.y + box.h) return box + } + return null + } + + /** + * A small disc at a group's centre, as the fallback press target. + * + * Never larger than the group's own territory, so it cannot reach past + * the group the way the old circle did. It yields to a content mark + * where they overlap, and they overlap often: marks sit CONTENT_PITCH + * apart, so below k = 22/2.6 = 8.46 their own HIT_MIN_PX targets tile the + * group's interior with no gaps at all. That is why the centre alone is + * not enough and the name is tried first. + */ + function pickCloudCentre(px, py, excludeTag) { + var world = screenToWorld(px, py) + var reach = HIT_MIN_PX / 2 / camera.scale + var nodes = pickableClouds(excludeTag) + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i] + if (Math.hypot(world.x - node.x, world.y - node.y) <= Math.min(reach, node.r)) { + return node + } + } + return null + } + + /** What a press on a group lands on: its name, or its centre. */ + function pickCloud(px, py) { + var label = pickLabel(px, py, "cloud") + if (label && state.universe[label.tag]) return state.universe[label.tag] + return pickCloudCentre(px, py) + } + + /** + * Inside the selection, as the DROP side sees it: the circle, which is + * what drawDrag highlights and is deliberately generous. Reaching past + * the drawn hull is harmless here -- see pickDropTarget. Null at the + * root, where there is nothing to narrow. + */ + function pickContainer(px, py) { + var container = containerRegion() + if (!container) return null + var world = screenToWorld(px, py) + return Math.hypot(world.x - container.x, world.y - container.y) <= container.r + ? container : null + } + + /** + * Inside the selection, as the PRESS side sees it: the hull that is + * actually stroked. The circle is `levelR` while the hull dips to + * HULL_FLOOR = 0.60 of its own peak, so pressing the empty paper in a + * sparse direction used to page the contents. + */ + function pickContainerDrawn(px, py) { + if (state.segments.length === 0) return null + var radii = containerRadii() + if (!radii) return null + var world = screenToWorld(px, py) + return Layout.pointInPolygon([Layout.hullRing(radii, 0, 0)], world.x, world.y) + ? containerRegion() : null + } + + /** + * One of an ancestor's remaining children -- a sibling of where you are. + * + * Tested as it is DRAWN: its name, then its own soft body, then a small + * disc at its centre. ancestorChildShape decides both whether it is + * drawn and where, so this cannot test a child the frame did not stroke. + * It used to test every child of every ancestor unconditionally, which + * is how a press on blank canvas grabbed a sibling that was not on + * screen -- see ancestorChildShape for what that measured. + * + * Its circle `child.r` is the territory, up to 2.1x the body's radius, + * which is why pressing well clear of a sibling used to grab it. + */ + function pickSibling(px, py) { + var named = pickLabel(px, py, "sibling") + if (named && named.node) return named.node + var world = screenToWorld(px, py) + var reach = HIT_MIN_PX / 2 / camera.scale + var best = null + state.ancestors.forEach(function (ancestor) { + ancestor.children.forEach(function (child) { + var shape = ancestorChildShape(ancestor, child) + if (!shape) return + var inside = false + if (shape.rings) { + // The rings live in the ancestor's own space; the draw + // scales them out, so the pick scales the point in -- + // through the SAME number, so a bloom cannot leave the + // tested polygon behind the stroked one. + inside = Layout.pointInPolygon(child.rings, + (world.x - shape.rings.x) / shape.rings.scale, + (world.y - shape.rings.y) / shape.rings.scale) + } + if (!inside) { + inside = Math.hypot(world.x - child.x, world.y - child.y) + <= Math.min(reach, shape.worldR) + } + // Smallest first, so a small sibling beside a big one is + // reachable. + if (inside && (!best || child.r < best.r)) best = child + }) + }) + return best + } + + /** + * The way out: a sibling to move to, or an ancestor's boundary to step + * back through. + */ + function pickAncestor(px, py) { + var sibling = pickSibling(px, py) + if (sibling) return { r: sibling.r, segments: sibling.segments, sibling: sibling } + // Otherwise: the nearest ancestor boundary we are standing on. The + // boundary is stroked as a HULL, so the band follows the hull rather + // than the circle it used to be -- the two differ by up to 40%. + var world = screenToWorld(px, py) + for (var i = 0; i < state.ancestors.length; i++) { + var ancestor = state.ancestors[i] + var edge = ancestor.r + if (ancestor.boundaryHull && ancestor.map) { + var local = Layout.hullRadiusAt(ancestor.boundaryHull, + world.x - ancestor.map.x, world.y - ancestor.map.y) + if (local !== null) edge = local * ancestor.map.scale + } + var here = Math.hypot(world.x - ancestor.x, world.y - ancestor.y) + if (Math.abs(here - edge) * camera.scale <= RING_HIT) { + return { r: ancestor.r, segments: ancestor.segments } + } + } + return null + } + + // ---- popup / info card ------------------------------------------------ + + function openPopup(node) { + closeInfoCard() + popupTarget = node + var popup = elements.popup + popup.textContent = "" + + var title = document.createElement("div") + title.className = "tagmap-popup-title" + title.textContent = node.tag + popup.appendChild(title) + + // Read the tag's own payload entry. No fallback to a different field: + // substituting the global `total` for a missing `count` promised + // content that a drill-down could not deliver. + var entry = tagEntry(node.tag) + var atRoot = state.segments.length === 0 + var atMaxDepth = state.segments.length >= MAX_DEPTH + var last = state.segments[state.segments.length - 1] + var atMaxWidth = last ? last.length >= MAX_WIDTH : false + var isSelected = selectedTags().indexOf(node.tag) >= 0 + // A SIBLING -- one of an ancestor's other children -- is not a child + // of this level, so it is not in coTags and tagEntry() has nothing + // for it. buildAncestors culls the two populations apart on purpose, + // so this is by construction rather than by accident. + var sibling = node.segments ? node : null + // What entering it gives, which is what its circle's size already + // states. For a child, coTags' `count` is the same promise measured + // against the current set. + var andCount = sibling + ? (typeof sibling.reach === "number" ? sibling.reach : null) + : (entry ? entry.count : null) + + var narrow = document.createElement("button") + narrow.className = "tagmap-popup-action" + // "Of the contents here, N carry this tag" -- a fact about the current + // set. It is a lower bound on the next view, which also admits + // similar-name matches; see the note under the header. + narrow.textContent = "▶ " + localize("この中に入る", "Enter") + + (andCount === null ? "" : " — " + localize("このうち ", "") + andCount + localize("件", "")) + if (sibling) { + // Sideways, not deeper: this is the level the sibling already + // stands for, so its own depth cap applies, not ours. + narrow.onclick = function () { closePopup(); navigate(sibling.segments) } + } else if (atMaxDepth || andCount === 0 || isSelected) { + narrow.disabled = true + narrow.title = atMaxDepth + ? localize("これ以上絞り込めません", "Cannot narrow down further") + : (isSelected + ? localize("すでに選択されています", "Already selected") + : localize("共通のコンテンツがありません", "No shared contents")) + } else { + narrow.onclick = function () { closePopup(); narrowDown(node.tag) } + } + popup.appendChild(narrow) + + // "View together" widens the segment you are IN, so it is only + // offered where the union is between peers. A depth-1 sibling is a + // peer of the current segment: at `/A/B/C`, sibling D of C makes + // `/A/B/C,D`. A deeper one is not -- sibling E of B belongs beside + // B, i.e. `/A/B,E/C`, which is not this segment -- so it is not + // offered here. Dragging E into B's own frame is the way to say + // that, and the frame names the segment unambiguously. + var canJoin = sibling ? sibling.depth === 1 : true + if (!atRoot && !isSelected && canJoin) { + // A delta against orBase, not against the header total: both are + // computed without similar-name matches, so the delta cannot go + // negative. Comparing orCount with the header (which includes + // them) made OR-adding a tag look like it shrank the set. + var orBase = state.data && state.data.stats ? state.data.stats.orBase : null + // Null for a sibling: `orCount` is measured per current level and + // a sibling is absent from that payload by construction, so there + // is no honest number to show. Substituting its own count would + // promise a union it has not measured -- the same trap the header + // note above warns about -- so the button goes without one. + var delta = (entry && typeof entry.orCount === "number" && typeof orBase === "number") + ? entry.orCount - orBase + : null + var join = document.createElement("button") + join.className = "tagmap-popup-action" + join.textContent = "+ " + localize("一緒に見る", "View together") + + (delta === null ? "" : " — +" + delta + localize("件", "")) + if (atMaxWidth) { + join.disabled = true + join.title = localize("このセグメントは満杯です", "This segment is full") + } else { + // Widens THIS level, matching the number shown (orCount is + // measured against the parent, i.e. this same segment). + join.onclick = function () { closePopup(); widenLevel(node.tag) } + } + popup.appendChild(join) + } + + popup.classList.add("visible") + positionPopup() + } + + function positionPopup() { + if (!popupTarget) return + var popup = elements.popup + var screen = worldToScreen(popupTarget.x, popupTarget.y) + var nodeR = popupTarget.r * camera.scale + var width = popup.offsetWidth || 190 + var height = popup.offsetHeight || 90 + var below = screen.y + Math.min(nodeR, 120) + 10 + var x = Math.min(Math.max(8, screen.x - width / 2), Math.max(8, cssW - width - 8)) + var y = below + height > cssH - 8 + ? Math.max(8, screen.y - Math.min(nodeR, 120) - height - 10) + : below + popup.style.transform = "translate(" + Math.round(x) + "px," + Math.round(y) + "px)" + } + + function closePopup() { + popupTarget = null + if (elements.popup) elements.popup.classList.remove("visible") + } + + /** + * Builds the card's contents from an item, read against the CURRENT + * selection. + * + * Separate from openInfoCard because the card now outlives a level change + * (see syncInfoCardToLevel). The DOM used to be built once and never + * rebuilt, which was harmless only because every navigation closed the + * card: the badge below froze the verdict it was opened with, and a card + * that survives a level change would have gone on asserting it about a + * level the reader had left. Everything here that reads state.segments + * has to be re-derivable, and calling this again is the whole repair. + */ + function renderInfoCard(item) { + var card = elements.infoCard + card.textContent = "" + + var title = document.createElement("div") + title.className = "tagmap-card-title" + title.textContent = item.title + (item.parentTitle ? " | " + item.parentTitle : "") + card.appendChild(title) + + // Do not let a name match pass for a tag match -- and do not let a + // verdict from another level pass for one about this level either. + // `suggested` is only meaningful in the selection it was resolved + // in, and bodies are deliberately cached across navigations, so a + // body carried in from elsewhere says nothing here. Silence is the + // honest answer: the badge exists to add a caveat, and an absent + // caveat claims nothing, while a wrong one misstates authorship. + if (item.suggested && item.forTagPath === tagPathOf(state.segments)) { + var badge = document.createElement("div") + badge.className = "tagmap-badge" + badge.textContent = localize( + "類似名タグからの候補", + "matched by similar tag name" + ) + card.appendChild(badge) + } + + if (item.summary) { + var summary = document.createElement("div") + summary.className = "tagmap-card-summary" + summary.innerHTML = item.summary // server-rendered summary HTML + card.appendChild(summary) + } + + var actions = document.createElement("div") + actions.className = "tagmap-card-actions" + var open = document.createElement("a") + open.href = item.url + open.textContent = localize("開く", "Open") + actions.appendChild(open) + var close = document.createElement("button") + close.textContent = localize("閉じる", "Close") + close.onclick = closeInfoCard + actions.appendChild(close) + card.appendChild(actions) + } + + function openInfoCard(star) { + // One overlay at a time: the popup is anchored to a tag and this to a + // content, and two answers to one tap is one too many. + closePopup() + // The item is kept, not just the key: it is what the card is rebuilt + // from when the level changes under it. It used to be written here + // and never read again. + infoTarget = { key: star.key, item: star.item } + renderInfoCard(star.item) + elements.infoCard.classList.add("visible") + } + + /** + * Carries the card across a level change when its content is still on the + * map, and closes it when it is not. + * + * A content keeps its key at every level. That is not an assumption: it + * is the fact marksThisFrame() matches an outgoing mark to an incoming + * one by raw key equality on, which is what lets a content present at two + * levels SLIDE instead of being replaced. applyData built that morph and + * then, thirty-odd lines later, closed the card unconditionally and + * without a word -- the same synchronous block asserting both that the + * content survived and that the card about it did not. + * + * The test is about the CONTENT, not the route. A card describes a + * content, so it stays open exactly as long as that content is something + * the reader can still see; a breadcrumb leap and a zoom get the same + * answer because the question is the same. drawInfoLinks already tolerates + * the gap in between -- with no instance on screen this frame it drops + * the lines and leaves the card alone. + * + * Rebuilt rather than left standing, because `suggested` is only + * meaningful in the selection it was resolved in and the card is built + * imperatively once. Re-rendering re-reads it, so the badge goes silent + * on a carried-over body instead of misstating authorship. + */ + function syncInfoCardToLevel() { + if (!infoTarget) return + var points = state.nebula ? state.nebula.points : [] + for (var i = 0; i < points.length; i++) { + if (points[i].key !== infoTarget.key) continue + if (infoTarget.item) renderInfoCard(infoTarget.item) + return + } + closeInfoCard() + } + + function closeInfoCard() { + infoTarget = null + if (elements.infoCard) elements.infoCard.classList.remove("visible") + } + + // ---- gestures --------------------------------------------------------- + + var pointers = {} + var gesture = null + + function canvasLeft() { return elements.canvasRect ? elements.canvasRect.left : 0 } + function canvasTop() { return elements.canvasRect ? elements.canvasRect.top : 0 } + + function setupGestures() { + var canvas = elements.canvas + + canvas.addEventListener("pointerdown", function (event) { + // Capture is an optimisation -- it keeps the drag alive when the + // pointer leaves the canvas -- and it throws if the id is not + // active any more. Unguarded, that exception aborted the rest of + // pointerdown, so the gesture never started at all: the whole + // interaction was lost to a nicety. + try { + canvas.setPointerCapture(event.pointerId) + } catch (error) { + // A pointer that is already gone still gets its gesture. + } + pointers[event.pointerId] = { x: event.clientX, y: event.clientY } + var ids = Object.keys(pointers) + if (ids.length === 2) { + drag = null + var a = pointers[ids[0]], b = pointers[ids[1]] + gesture = { + mode: "pinch", + startDist: Math.hypot(a.x - b.x, a.y - b.y), + startScale: camera.scale, + anchor: screenToWorld((a.x + b.x) / 2 - canvasLeft(), (a.y + b.y) / 2 - canvasTop()), + } + // One gesture, one burst -- the wheel's contract, reached + // without the wheel's guesswork: a wheel burst has to be + // inferred from a 140 ms gap because macOS keeps sending + // decaying events after the fingers lift, whereas a pinch + // says when it begins and when it ends. + // + // Without a burst, `replace = !!zoomBurst` was always false + // on touch, so every crossing pushed its own history entry -- + // going in and straight back out left two where the wheel + // leaves none -- and ZOOM_MAX_PER_BURST never capped a chain. + // The timer is cleared because a wheel burst still winding + // down would otherwise fire and close this one mid-gesture. + clearTimeout(wheelBurstTimer) + endZoomBurst() + zoomBurst = { + entryPath: tagPathOf(state.segments), + entryHref: buildHref(state.segments), + transitions: 0, + // The wheel uses this to segment a stream it cannot + // otherwise cut. Nothing has to guess where this gesture + // ends, and leaving it at 0 means a wheel arriving during + // a pinch is correctly treated as a different gesture. + endsAt: 0, + } + cameraTarget = null + return + } + if (ids.length !== 1) return + + var px = event.clientX - canvasLeft() + var py = event.clientY - canvasTop() + // A sibling is a drag source too, so it can be pulled into the + // selection. The current level's own children win, as everywhere + // else: the level you are in beats the context behind it. + var cloud = pickCloud(px, py) + var sibling = cloud ? null : pickSibling(px, py) + var held = cloud || sibling + gesture = { + mode: held ? "node" : "pan", + tag: held ? held.tag : null, + sibling: sibling || null, + startX: event.clientX, startY: event.clientY, + startedAt: performance.now(), + camX: camera.x, camY: camera.y, + moved: false, + } + }) + + canvas.addEventListener("pointermove", function (event) { + var pointer = pointers[event.pointerId] + if (!pointer || !gesture) return + pointer.x = event.clientX + pointer.y = event.clientY + + if (gesture.mode === "pinch") { + var ids = Object.keys(pointers) + if (ids.length < 2) return + var a = pointers[ids[0]], b = pointers[ids[1]] + var wanted = gesture.startScale * Math.hypot(a.x - b.x, a.y - b.y) / gesture.startDist + var scale = clampScale(wanted) + // A pinch pushes against the clamps like a wheel does, so it + // feeds the same intent accumulator. + var ceiling = zoomCeiling(), floor = zoomFloor() + if (wanted > ceiling) zoomPush = Math.min(ZOOM_PUSH_CAP, Math.log(wanted / ceiling)) + else if (wanted < floor) zoomPush = Math.max(-ZOOM_PUSH_CAP, -Math.log(floor / wanted)) + else zoomPush = 0 // the intent is not ours to clear; see applyZoom + var mid = { x: (a.x + b.x) / 2 - canvasLeft(), y: (a.y + b.y) / 2 - canvasTop() } + camera.scale = scale + camera.x = gesture.anchor.x - (mid.x - cssW / 2) / scale + camera.y = gesture.anchor.y - (mid.y - cssH / 2) / scale + considerZoomTransition(mid.x, mid.y, + wanted > gesture.startScale ? 1 : (wanted < gesture.startScale ? -1 : 0)) + return + } + + var dx = event.clientX - gesture.startX + var dy = event.clientY - gesture.startY + var distance = Math.hypot(dx, dy) + + if (gesture.mode === "pan") { + if (distance > TAP_MOVE_PX) gesture.moved = true + cameraTarget = null + camera.x = gesture.camX - dx / camera.scale + camera.y = gesture.camY - dy / camera.scale + return + } + + // What a drop means depends on what is being dragged. + // + // a child of this level onto another child = enter both + // past the boundary = leave + // a SIBLING inside the boundary = absorb it into + // this segment, which is what the frame + // you dropped it in says + // + // So: inward absorbs, outward leaves. + if (distance > DRAG_START_PX) { + gesture.moved = true + var px = event.clientX - canvasLeft() + var py = event.clientY - canvasTop() + var target = null + if (gesture.sibling) { + // Only the current selection's frame absorbs. An + // ancestor's frame would mean something else again, and + // is left out until it is asked for. + if (pickContainer(px, py)) target = { kind: "absorb" } + } else { + // A sibling wins over the interior: children are packed + // well inside it, so testing the interior first made the + // ones near the centre impossible to drop onto. + var cloud = pickDropTarget(px, py, gesture.tag) + if (cloud) { + target = { kind: "cloud", tag: cloud.tag } + } else if (!pickContainer(px, py)) { + target = { kind: "out" } + } + } + drag = { + tag: gesture.tag, x: px, y: py, target: target, + sibling: gesture.sibling || null, + } + } + }) + + function endPointer(event) { + var pointer = pointers[event.pointerId] + delete pointers[event.pointerId] + if (!gesture) { drag = null; return } + + if (gesture.mode === "pinch") { + if (Object.keys(pointers).length < 2) { + gesture = null + // Fingers lifted: the gesture is over, so its burst is + // too. endZoomBurst() calls releaseZoom() -- the band + // springing back to the clamp -- and additionally settles + // the history the crossings replaced along the way. + endZoomBurst() + } + return + } + + var quick = performance.now() - gesture.startedAt < TAP_MS + if (gesture.mode === "node" && drag) { + var target = drag.target + var sourceTag = drag.tag + drag = null + gesture = null + // A new level holding both, NOT a wider version of this one: + // widening would select a superset of what is on screen. + if (target && target.kind === "cloud") enterTogether([sourceTag, target.tag]) + else if (target && target.kind === "out") leaveLevel() + // The frame it was dropped in names the segment that takes + // it, so widenLevel needs no argument beyond the tag. + else if (target && target.kind === "absorb") widenLevel(sourceTag) + return + } + if (!gesture.moved && quick && pointer) { + handleTap(event.clientX - canvasLeft(), event.clientY - canvasTop()) + } + drag = null + gesture = null + } + canvas.addEventListener("pointerup", endPointer) + + /** + * A cancelled gesture must NOT commit. An Android long-press menu, an + * OS edge swipe or a lost capture all arrive here, and running the + * drop would perform a navigation the user explicitly aborted. + */ + function abortGesture(event) { + if (event && event.pointerId !== undefined) delete pointers[event.pointerId] + else pointers = {} + drag = null + dragHint = null + gesture = null + meltPath = null + releaseZoom() + // A pinch that crossed the threshold and was then cancelled must + // not navigate: the reader did not finish the gesture, and the + // system cancelling it is not their decision. + abortZoomIntent() + } + canvas.addEventListener("pointercancel", abortGesture) + canvas.addEventListener("lostpointercapture", abortGesture) + window.addEventListener("blur", function () { abortGesture(null) }) + + canvas.addEventListener("wheel", function (event) { + event.preventDefault() + cameraTarget = null + // ctrlKey on a wheel event is a trackpad pinch, not a scroll. + // Same intent, so the same path -- just a different sensitivity. + var factor = Math.exp(-event.deltaY * (event.ctrlKey ? 0.008 : 0.0012)) + var px = event.clientX - canvasLeft() + var py = event.clientY - canvasTop() + + var now = performance.now() + if (!zoomBurst || now > zoomBurst.endsAt) { + endZoomBurst() + zoomBurst = { + entryPath: tagPathOf(state.segments), + entryHref: buildHref(state.segments), + transitions: 0, + endsAt: 0, + } + } + zoomBurst.endsAt = now + WHEEL_BURST_GAP_MS + clearTimeout(wheelBurstTimer) + wheelBurstTimer = setTimeout(endZoomBurst, WHEEL_BURST_GAP_MS + 20) + + applyZoom(factor, px, py) + }, { passive: false }) + + // iOS Safari: block the page pinch while over the canvas only. + canvas.addEventListener("gesturestart", function (event) { event.preventDefault() }) + } + + /** + * One ordered list, so nothing can be drawn in a place where something + * else answers for it. Smallest-first inside each layer, and the current + * level always beats the context behind it. + */ + function handleTap(px, py) { + // An open POPUP still swallows the next tap: it is a menu, and a menu + // has to be dismissed before anything behind it answers. + if (popupTarget) { + closePopup() + return + } + // A name outranks a mark: pressing the word "OS" cannot plausibly + // mean the article whose card happens to sit under it. + var named = pickLabel(px, py) + if (named && named.kind === "cloud" && state.universe[named.tag]) { + openPopup(state.universe[named.tag]) + return + } + if (named && named.kind === "sibling" && named.node) { + openPopup(named.node) + return + } + var star = pickStar(px, py) + if (star) { + // A tap NEVER leaves the map. It used to, once the mark was big + // enough to show an excerpt, on the reasoning that the card was + // then saying what a detail card would. But that put an + // unconfirmed, unundoable navigation under an ordinary tap on + // the reading surface, and a 198x140 card holds five lines of a + // summary, not the article. Leaving is the detail card's "open" + // link and nothing else. + // + // Tapping a DIFFERENT content replaces what the card shows + // rather than closing it, so reading through a level costs one + // tap per content instead of two. + if (infoTarget && infoTarget.key === star.key) { + closeInfoCard() + return + } + if (star.item) openInfoCard(star) + else fetchOneBody(star.key).then(function (item) { + if (item) openInfoCard({ key: star.key, item: item, mark: star.mark }) + }) + return + } + // Nothing content-like under the finger: an open card takes the tap + // as "done". + if (infoTarget) { closeInfoCard(); return } + // The centre disc, AFTER the marks: where a mark sits on it the + // mark is the more specific object, and the name above still works. + var cloud = pickCloudCentre(px, py) + if (cloud) { openPopup(cloud); return } + // Inside the boundary but on none of them: page the contents. + if (pickContainerDrawn(px, py)) { + if (state.data && state.data.contents.hasMore) loadMore() + return + } + // Outside it: the context behind, which is the way back out. + var ancestor = pickAncestor(px, py) + if (!ancestor) return + // A sibling ASKS. Jumping straight to it threw away the segment the + // reader was in -- `/Arduino/OS` became `/Arduino/Stack` with no + // confirmation -- while the thing they usually want, seeing the two + // together, had no gesture at all. + if (ancestor.sibling) { openPopup(ancestor.sibling); return } + navigate(ancestor.segments) + } + + // ---- chrome ----------------------------------------------------------- + + /** + * Fetching never blocks or dims anything: the map stays interactive and + * only a thin line at the top edge says that something is on its way. + */ + function setLoading(loading) { + clearTimeout(loadingTimer) + if (loading) { + loadingTimer = setTimeout(function () { + elements.progress.classList.add("visible") + }, 200) + } else { + elements.progress.classList.remove("visible") + } + } + + function showToast(message, onRetry) { + var toast = elements.toast + toast.textContent = "" + var text = document.createElement("span") + text.textContent = message + toast.appendChild(text) + if (onRetry) { + var retry = document.createElement("button") + retry.textContent = localize("再試行", "Retry") + retry.onclick = function () { toast.classList.remove("visible"); onRetry() } + toast.appendChild(retry) + } + toast.classList.add("visible") + clearTimeout(toast._timer) + toast._timer = setTimeout(function () { toast.classList.remove("visible") }, 6000) + } + + function renderBreadcrumb() { + var container = elements.breadcrumb + container.textContent = "" + + var rootChip = document.createElement("button") + rootChip.className = "tagmap-chip tagmap-chip-root" + (state.segments.length === 0 ? " current" : "") + rootChip.textContent = "TagMap" + rootChip.onclick = function () { navigate([]) } + container.appendChild(rootChip) + + var chipCounts = {} + if (state.data) { + (state.data.chipTags || []).forEach(function (c) { chipCounts[c.tag] = c.count }) + } + + state.segments.forEach(function (segment, segmentIndex) { + var isLast = segmentIndex === state.segments.length - 1 + var box = document.createElement("span") + box.className = "tagmap-segment" + + segment.forEach(function (tag) { + var chip = document.createElement("span") + chip.className = "tagmap-chip" + var label = document.createElement("span") + label.textContent = tag + (isLast && chipCounts[tag] !== undefined ? " " + chipCounts[tag] : "") + chip.appendChild(label) + var remove = document.createElement("button") + remove.className = "tagmap-chip-remove" + remove.setAttribute("aria-label", "remove " + tag) + remove.textContent = "×" + remove.onclick = function () { removeTag(segmentIndex, tag) } + chip.appendChild(remove) + box.appendChild(chip) + }) + + var removeSegmentButton = document.createElement("button") + removeSegmentButton.className = "tagmap-segment-remove" + removeSegmentButton.setAttribute("aria-label", "remove segment") + removeSegmentButton.textContent = "×" + removeSegmentButton.onclick = function () { removeSegment(segmentIndex) } + box.appendChild(removeSegmentButton) + + container.appendChild(box) + if (!isLast) { + var separator = document.createElement("span") + separator.className = "tagmap-separator" + separator.textContent = "/" + container.appendChild(separator) + } + }) + } + + // Hidden mirror of the scene: screen readers, and a deterministic E2E hook. + function rebuildSrList() { + var list = elements.srList + list.textContent = "" + if (!state.data) return + // Same numbers, same wording as the canvas: `count` in the present + // tense, OR as a delta from stats.orBase. + var orBase = state.data.stats ? state.data.stats.orBase : null + function addTag(entry) { + var row = document.createElement("div") + row.setAttribute("role", "listitem") + if (entry.count > 0) { + var narrow = document.createElement("button") + narrow.textContent = entry.tag + " " + localize("で絞り込む", "narrow down") + + " (" + localize("このうち ", "") + entry.count + ")" + narrow.setAttribute("data-tagmap-action", "and") + narrow.setAttribute("data-tag", entry.tag) + narrow.onclick = function () { narrowDown(entry.tag) } + row.appendChild(narrow) + } + if (typeof entry.orCount === "number" && typeof orBase === "number") { + var join = document.createElement("button") + join.textContent = entry.tag + " " + localize("を一緒に見る", "view together") + + " (+" + (entry.orCount - orBase) + ")" + join.setAttribute("data-tagmap-action", "or") + join.setAttribute("data-tag", entry.tag) + join.onclick = function () { widenLevel(entry.tag) } + row.appendChild(join) + } + if (row.childNodes.length > 0) list.appendChild(row) + } + (state.data.coTags || []).forEach(addTag) + ;(state.data.suggestedTags || []).forEach(addTag) + ;(state.data.contents.items || []).forEach(function (item) { + var row = document.createElement("div") + row.setAttribute("role", "listitem") + var link = document.createElement("a") + link.href = item.url + link.textContent = item.title + + (item.suggested ? localize("(類似名タグから)", " (similar tag name)") : "") + row.appendChild(link) + list.appendChild(row) + }) + } + + function updateNote() { + if (!state.data) return + var parts = [] + if (state.data.totalCoTags > (state.data.coTags || []).length) { + parts.push(localize("上位", "Top ") + state.data.coTags.length + " / " + state.data.totalCoTags) + } + parts.push(localize( + "タップ=入る/隣へドラッグ=一緒に入る/外へドラッグ=出る", + "Tap to enter · drag onto a neighbour to enter both · drag out to leave" + )) + elements.note.textContent = parts.join(" · ") + } + + function buildChrome() { + var app = elements.app + app.textContent = "" + + elements.canvas = document.createElement("canvas") + elements.canvas.className = "tagmap-canvas" + elements.canvas.setAttribute("role", "img") + elements.canvas.setAttribute("aria-label", localize("タグマップ", "Tag map")) + app.appendChild(elements.canvas) + ctx = elements.canvas.getContext("2d") + + elements.breadcrumb = document.createElement("div") + elements.breadcrumb.className = "tagmap-breadcrumb" + app.appendChild(elements.breadcrumb) + + elements.popup = document.createElement("div") + elements.popup.className = "tagmap-popup" + app.appendChild(elements.popup) + + elements.infoCard = document.createElement("div") + elements.infoCard.className = "tagmap-info-card" + app.appendChild(elements.infoCard) + + elements.note = document.createElement("div") + elements.note.className = "tagmap-field-note" + app.appendChild(elements.note) + + elements.progress = document.createElement("div") + elements.progress.className = "tagmap-progress" + app.appendChild(elements.progress) + + elements.toast = document.createElement("div") + elements.toast.className = "tagmap-toast" + app.appendChild(elements.toast) + + elements.srList = document.createElement("div") + elements.srList.className = "tagmap-sr-only" + elements.srList.setAttribute("role", "list") + app.appendChild(elements.srList) + + setupGestures() + resizeCanvas() + } + + function resizeCanvas() { + var rect = elements.canvas.getBoundingClientRect() + elements.canvasRect = rect + cssW = rect.width + cssH = rect.height + dpr = Math.min(2, window.devicePixelRatio || 1) + elements.canvas.width = Math.round(cssW * dpr) + elements.canvas.height = Math.round(cssH * dpr) + buildStarLayer() + } + + // ---- bootstrap -------------------------------------------------------- + + /** + * Rejects an inline initial state written by an older server (a stale + * cached page whose shape this build cannot read). Probes the newest + * fields, not merely the presence of an array. + */ + function isCurrentShape(data) { + if (!data || !data.coTags || !data.stats || !data.contents) return false + if (!Array.isArray(data.segments)) return false + if (data.stats.directContents === undefined) return false + if (data.stats.childContents === undefined) return false + if (data.coTags.length > 0 && !Array.isArray(data.coTags[0].tags)) return false + if (data.coTags.length > 0 && data.coTags[0].total === undefined) return false + if (!Array.isArray(data.memberships)) return false + return true + } + + function init() { + elements.app = document.getElementById("tagmap-app") + if (!elements.app) return + + state.serviceUri = readMeta("service-uri") + state.contentPath = readMeta("content-path") + state.csrfToken = readMeta("token") + + // Take the caps from the server rather than trusting the constants: + // otherwise raising TAGMAP_MAX_* leaves the client refusing paths the + // server would accept, and lowering it offers ones it will reject. + var depthCap = parseInt(readMeta("tagmap-max-depth"), 10) + var widthCap = parseInt(readMeta("tagmap-max-width"), 10) + if (depthCap > 0) MAX_DEPTH = depthCap + if (widthCap > 0) MAX_WIDTH = widthCap + + var layerMatch = location.search.match(/[?&]layer=([^&]+)/) + if (layerMatch) state.layer = decodeURIComponent(layerMatch[1]) + + var initial = null + var inline = document.getElementById("tagmap-initial-state") + if (inline) { + try { initial = JSON.parse(inline.textContent) } catch (error) { /* ignore */ } + } + + buildChrome() + + if (initial && isCurrentShape(initial)) { + state.layer = initial.layer || state.layer + applyData(initial, { replace: true }) + } else { + navigate(currentUrlSegments(), { replace: true }) + } + + window.addEventListener("popstate", function () { + // history.state is intentionally empty: the URL is the identity, + // and the payload usually comes from the in-memory cache. + navigate(currentUrlSegments(), { fromHistory: true }) + }) + + var resizeTimer = null + window.addEventListener("resize", function () { + clearTimeout(resizeTimer) + resizeTimer = setTimeout(resizeCanvas, 200) + }) + + document.addEventListener("visibilitychange", function () { + if (!document.hidden) ensureLoop() + }) + + window.addEventListener("keydown", function (event) { + if (event.key === "Escape") { closePopup(); closeInfoCard() } + }) + + if (window.ThemeChanger && ThemeChanger.onChangeThemeCallbacks) { + ThemeChanger.onChangeThemeCallbacks.push(onThemeChanged) + } + + ensureLoop() + } + + TM.state = state + TM.camera = camera + + /** + * Screen-space snapshot: turns every visual claim into a number, so a + * test can assert "the entered circle stayed put and the siblings did not + * scatter" instead of a person squinting at a screenshot. Radii and + * positions come through the same worldToScreen the renderer uses. + */ + TM.snapshot = function () { + var toScreen = function (item) { + var screen = worldToScreen(item.x, item.y) + return { + tag: item.tag, + x: Math.round(screen.x * 100) / 100, + y: Math.round(screen.y * 100) / 100, + r: Math.round(item.r * camera.scale * 100) / 100, + } + } + var container = containerRegion() + return { + tagPath: state.data ? state.data.tagPath : null, + segments: state.segments, + settled: cameraTarget === null, + camera: { x: camera.x, y: camera.y, scale: camera.scale }, + viewport: { w: cssW, h: cssH }, + current: container ? toScreen({ tag: "(current)", x: 0, y: 0, r: state.levelR }) : null, + children: state.layout.slots.map(function (slot) { + return toScreen(state.universe[slot.tag]) + }), + ancestors: state.ancestors.map(function (ancestor) { + return { + depth: ancestor.depth, + tagPath: tagPathOf(ancestor.segments), + boundary: toScreen({ tag: "(boundary)", x: ancestor.x, y: ancestor.y, r: ancestor.r }), + // World as well as screen: the containment and no-overlap + // claims about an ancestor's field are camera-independent, + // and the camera is mid-ease at a transition. + wx: ancestor.x, + wy: ancestor.y, + wr: ancestor.r, + children: ancestor.children.map(function (child) { + return Object.assign( + { wx: child.x, wy: child.y, wr: child.r }, toScreen(child)) + }), + } + }), + groups: state.layout.slots.map(function (slot) { + var node = state.universe[slot.tag] + return { tag: slot.tag, tags: node ? node.tags : null, count: node ? node.count : null } + }), + nebula: state.nebula ? { + // World coordinates as well as screen ones: the continuity + // assertions compare across a navigation, and at that instant + // the camera is mid-ease, so screen coordinates cannot say + // whether anything actually moved. + points: state.nebula.points.map(function (point) { + var screen = worldToScreen(point.x, point.y) + return { + key: point.key, + group: point.group, + inBand: point.inBand, + wx: point.x, + wy: point.y, + x: Math.round(screen.x * 100) / 100, + y: Math.round(screen.y * 100) / 100, + } + }), + contours: state.nebula.contours.map(function (c) { + return { + tag: c.tag, reach: c.reach, members: c.members, + rings: c.rings.length, area: c.area, + } + }), + anchors: state.nebula.anchors.map(function (a) { + return { tag: a.tag, reach: a.reach, wx: a.x, wy: a.y, wr: a.r } + }), + levelR: state.nebula.levelR, + packExtent: state.nebula.packExtent, + extent: state.nebula.extent, + // CSS px per content radius, so "a content is N px" is + // directly assertable rather than re-derived from the camera. + contentPx: contentPx(), + truncated: state.nebula.truncated, + } : null, + // The zoom, as a set of numbers: "you cannot zoom past a + // content" and "pushing the ceiling is the enter gesture" both + // become assertions rather than intentions. + zoom: { + k: camera.scale, + contentPx: contentPx(), + fit: fitScale(), + floor: zoomFloor(), + ceiling: zoomCeiling(), + atCeiling: camera.scale >= zoomCeiling() - 1e-9, + atFloor: camera.scale <= zoomFloor() + 1e-9, + push: Math.round(zoomPush * 1000) / 1000, + // heldMs answers "why has this not committed yet": the dwell + // is the only reason a pending transition waits. + pending: zoomPending ? { + kind: zoomPending.kind, + tag: zoomPending.tag, + heldMs: Math.round(performance.now() - zoomPending.since), + } : null, + burst: zoomBurst ? zoomBurst.transitions : null, + disabled: zoomDisabled, + }, + // Why a content is or is not named, as numbers. + labels: state.labels || null, + // The group-name half of the same question `labels.anonymous` + // asks about contents: of the groups that wanted a name this + // frame, how many got one, and what turned the rest away. + names: nameStats, + // The connector endpoints drawn this frame: one per instance of + // the content the detail card is showing. A test counts lines + // here rather than a reader counting them in a screenshot. + links: infoTarget ? { key: infoTarget.key, drawn: linkEnds.slice() } : null, + stats: state.data ? state.data.stats : null, + pending: Object.keys(ancestorFetches).concat(Object.keys(bodyFetches)), + } + } + + /** The very functions the gestures call, so tests exercise real paths. */ + TM.act = { + enter: narrowDown, + enterTogether: enterTogether, + widen: widenLevel, + leave: leaveLevel, + navigate: function (tagPath) { navigate(parseTagPath(tagPath)) }, + loadMore: loadMore, + // Zooming, as the reader does it. Without these the dwell, the + // cooldown and the runaway backstop cannot be tested -- and an + // untestable transition loop is the worst failure this feature has. + wheel: function (deltaY, px, py) { + var factor = Math.exp(-deltaY * 0.0012) + var now = performance.now() + if (!zoomBurst || now > zoomBurst.endsAt) { + endZoomBurst() + zoomBurst = { + entryPath: tagPathOf(state.segments), + entryHref: buildHref(state.segments), + transitions: 0, endsAt: 0, + } + } + zoomBurst.endsAt = now + WHEEL_BURST_GAP_MS + cameraTarget = null + applyZoom(factor, px === undefined ? cssW / 2 : px, py === undefined ? cssH / 2 : py) + }, + endWheelBurst: endZoomBurst, + releaseZoom: releaseZoom, + zoomTo: function (scale) { + cameraTarget = null + camera.scale = clampScale(scale) + zoomPush = 0 + zoomPending = null + }, + // handleTap's own order, so a test asserts what a finger would do. + hitTest: function (px, py) { + var named = pickLabel(px, py) + if (named && named.kind === "cloud" && state.universe[named.tag]) { + return { kind: "name", of: state.universe[named.tag].kind, tag: named.tag } + } + if (named && named.kind === "sibling" && named.node) { + return { kind: "name", of: "sibling", tag: named.tag } + } + var star = pickStar(px, py) + // The url is only known once the body has arrived; the key is + // always known, because that is what the manifest carries. + if (star) { + return { + kind: "content", + key: star.key, + url: star.item ? star.item.url : null, + title: star.item ? star.item.title : null, + } + } + var cloud = pickCloudCentre(px, py) + if (cloud) return { kind: cloud.kind, tag: cloud.tag } + if (pickContainerDrawn(px, py)) return { kind: "interior" } + var ancestor = pickAncestor(px, py) + if (ancestor) { + return ancestor.sibling + ? { kind: "sibling", tag: ancestor.sibling.tag, + depth: ancestor.sibling.depth, + tagPath: tagPathOf(ancestor.segments) } + : { kind: "ancestor", tagPath: tagPathOf(ancestor.segments) } + } + return { kind: "empty" } + }, + /** What a PRESS would start: a node gesture, or a pan. */ + pressTest: function (px, py) { + var cloud = pickCloud(px, py) + if (cloud) return { mode: "node", kind: cloud.kind, tag: cloud.tag } + var sibling = pickSibling(px, py) + if (sibling) return { mode: "node", kind: "sibling", tag: sibling.tag } + return { mode: "pan" } + }, + /** What a DROP there would do, for the given source tag. */ + dropTest: function (px, py, sourceTag, asSibling) { + if (asSibling) return pickContainer(px, py) ? { kind: "absorb" } : { kind: null } + var cloud = pickDropTarget(px, py, sourceTag) + if (cloud) return { kind: "cloud", tag: cloud.tag } + return pickContainer(px, py) ? { kind: null } : { kind: "out" } + }, + } + + /** + * The gate recovery's state, so a test can assert it instead of a person + * inferring it from the network panel. + * + * `renewing` is what proves single-flight: three callers can get 428 in + * one frame, and the only externally visible difference between one proof + * and three is that the seed is requested once. `sweeping` is the + * regression that matters most -- a 428 used to leave the body sweep's + * mutex set forever, which was invisible because the page was going away. + */ + TM.gate = function () { + return { + renewing: !!gateRenewal, + failedAt: gateFailedAt, + sweeping: bodySweepInFlight, + } + } + + /** Resolves once the camera has settled, for deterministic assertions. */ + TM.settle = function (timeoutMs) { + var deadline = Date.now() + (timeoutMs || 5000) + return new Promise(function (resolve) { + var quiet = 0 + // setTimeout, not requestAnimationFrame: a hidden tab runs no + // frames, so a frame-driven wait would never resolve there -- + // and never reach the document.hidden test either. The deadline + // is the backstop for a camera that never settles. + var check = function () { + quiet = cameraTarget === null ? quiet + 1 : 0 + if (quiet >= 2 || document.hidden || Date.now() > deadline) { + resolve(TM.snapshot()) + return + } + setTimeout(check, 16) + } + check() + }) + } + + /** + * What a frame costs on the main thread, in milliseconds. + * + * Report this, not a frame rate: on a 30 Hz display every frame is 33.3 + * ms apart no matter how cheap the drawing is, so the rate cannot tell a + * heavy renderer from a slow screen. `p95` against a 16.7 ms budget is + * the number that decides whether the drawing fits in a frame. + */ + TM.perf = function (reset) { + var recent = perf.recent.slice().sort(function (a, b) { return a - b }) + var at = function (share) { + return recent.length === 0 ? 0 + : Math.round(recent[Math.min(recent.length - 1, + Math.floor(recent.length * share))] * 100) / 100 + } + var report = { + frames: perf.frames, + mean: perf.frames === 0 ? 0 : Math.round(perf.total / perf.frames * 100) / 100, + median: at(0.5), + p95: at(0.95), + worst: Math.round(perf.worst * 100) / 100, + budget: 16.7, + window: recent.length, + contours: state.nebula ? state.nebula.contours.length : 0, + points: state.nebula ? state.nebula.points.length : 0, + } + if (reset) perf = { frames: 0, total: 0, worst: 0, recent: [] } + return report + } + + window.TagMap = TM + + // Last: applyData() reads TM.snapshot, so the hooks must exist first. + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) + } else { + init() + } +})() diff --git a/Client/TagMap/tagmap-layout.js b/Client/TagMap/tagmap-layout.js new file mode 100644 index 0000000..527e77c --- /dev/null +++ b/Client/TagMap/tagmap-layout.js @@ -0,0 +1,1474 @@ +/* + * TagMap layout: the pure geometry of the nested-circle model. + * + * The selection path IS the nesting. `/Library/C#` means "the root field + * contains Library's circle, which contains C#'s circle", and a group's + * position is always a territory inside its parent. That makes "the circle + * you click is the circle you enter" a property of the model rather than + * behaviour to maintain. + * + * Everything here is a pure function of payload data. No coordinates are + * ever sent by the server (layout is a presentation concern), no randomness + * and no clock, so the same tag path yields the same picture for every + * visitor and a revisit reproduces it exactly. That also lets this file be + * required from Node and checked mechanically -- determinism, non-overlap + * and depth-5 precision are exactly the properties you cannot eyeball. + * + * Coordinates are ABSOLUTE: one content radius is the world unit, at every + * depth. A region's size therefore states a content count that means the + * same thing everywhere on the map, and the zoom range is finite -- it grows + * only as sqrt(N). The older model renormalised the current level to radius + * 1 on every step, which made depth invisible and the zoom unbounded. + * + * A content belonging to several groups is DRAWN IN EACH of them. That is + * the one place this file trades a property away, and it is deliberate: a + * single mark per content has to sit between its groups, which stretches + * both outlines toward it, and with a hundred groups every outline crosses + * every other. Measured on the reference corpus, all 5565 outline pairs + * crossed; duplicating the mark brings that to zero. Identity lives in the + * `key` instead of in the geometry, so the instances of one content can be + * tied together when the reader asks rather than always. + */ +;(function (root, factory) { + if (typeof module === "object" && module.exports) module.exports = factory() + else root.TagMapLayout = factory() +})(typeof self !== "undefined" ? self : this, function () { + "use strict" + + var TAU = Math.PI * 2 + var GOLDEN_ANGLE = 2.399963229728653 + + // Children are packed within this share of the parent radius; the ring + // outside it is the free band where direct contents sit. + var INNER_FILL = 0.78 + var BAND_RINGS = [0.815, 0.885, 0.95] + var FUSE_PAD = 1.04 + // Contents shown inside a child group stay within this share of it. + var INSIDE_FILL = 0.66 + + // --- density field --- + // Isolevel of the contour. 0.5 is the natural midpoint of the bounded + // union, and kernelRadius() is derived from it so that a lone point + // reproduces the circle exactly. + var FIELD_T = 0.5 + // Kernel radius as a multiple of the points' own spacing. Derived, not + // tuned: at 0.50 two contents one CONTENT_PITCH apart draw a single ring + // and two at 1.4 pitches draw two, so "these read as one body" means + // exactly "these sit at the packing density". + var KERNEL_SPACING = 0.50 + // Points sampled when sizing a kernel from a group's actual spread. The + // mean-nearest-neighbour scan is O(m²): at the manifest cap of 4000 rows + // in one group that is 16M distance computations. Sampling by index keeps + // it deterministic. + var KERNEL_SAMPLE_MAX = 400 + + // --- the absolute scale --- + // One content radius IS the world unit, so nothing is renormalised per + // level and the zoom range is finite: it grows only as sqrt(N). + var CONTENT_R = 1 + // Centre-to-centre minimum between two contents: a 0.6 R gutter. + var CONTENT_PITCH = 2.6 + // Radius per sqrt(content). PACK_K = (CONTENT_PITCH/2)/sqrt(fill) with a + // fill of 0.25, which lands exactly on the pitch -- so a region holding n + // contents is sqrt(n) pitches across. Measured on the reference corpus: + // at a fill of 0.40 six times as many content discs actually overlapped. + var PACK_K = CONTENT_PITCH + + // --- the card a content grows into --- + // + // A content's title and summary go INSIDE its own mark, not beside it. + // Beside it they collide, and the collision test is both expensive and + // arbitrary: measured on /Arduino, 70-77 marks had a title to show and + // only 7-12 got the space, chosen by nothing but distance from the + // screen centre. Inside the mark, collision is impossible -- placeMarks + // guarantees two marks are at least CONTENT_PITCH apart, which + // placement.test.js already checks. + // + // Two axis-aligned w x h rectangles whose centres are `d` apart at angle + // t miss each other when d|cos t| >= w OR d|sin t| >= h. Holding for + // EVERY t requires the DIAGONAL to fit: sqrt(w^2 + h^2) <= d. Imposing + // the silver ratio w/h = sqrt(2) then fixes both sides. + // + // So the card is derived from CONTENT_PITCH and introduces no new + // constant of its own. The largest card the REAL data would allow is 11% + // bigger (2.40 x 1.70, diagonal 2.94), but only because no real pair sits + // at the worst angle of 35.3 degrees -- that is an accident of the + // corpus, not a guarantee, so the derived size is used instead. + var CARD_ASPECT = Math.SQRT2 + var CARD_H = CONTENT_PITCH / Math.sqrt(1 + CARD_ASPECT * CARD_ASPECT) + var CARD_W = CARD_H * CARD_ASPECT + + // Landscape rather than portrait, decided by measurement: at a card + // height of 140 px, landscape gives 16 CJK characters per line and + // portrait gives 7. Seven is not a Japanese title. + + // Type inside the card, in CSS px. Fixed sizes: text never scales with + // the camera, which is what stopped the old thresholds feeling arbitrary. + var CARD_PAD = 8 + var CARD_TITLE_PX = 12 + var CARD_BODY_PX = 11 + var CARD_LINE_PX = 15 + // Height at which the card holds a full excerpt -- two title lines, the + // parent, and five of summary. The zoom ceiling is derived from this. + var CARD_FULL_PX = 140 + + var GRID_N = 25 // samples per axis over the group's own space + var EPSILON = 1e-9 + + // ---- the absolute scale ------------------------------------------------ + + /** + * The radius a group occupies, from what entering it yields. + * + * Sized by `reach` (the next view's content count), never by `count` + * (this view's). They differ for 11% of groups on the reference corpus + * and by up to 4x, so a circle drawn from `count` is not the size of the + * view behind it. + * + * The circle therefore states exactly the number its label states. It is + * NOT the size the level behind it will be: that level draws a mark per + * (content, group) pair, so it needs 1.5-2.3x this radius and blooms when + * entered, pushing the siblings outward. Reserving the room up front + * cannot fix that -- the factor is scale-invariant, so padding every + * anchor scales the level too and cancels out -- and it would cost the + * agreement between the circle and its label, which is worth more. + */ + function territoryRadius(contentCount) { + return PACK_K * Math.sqrt(Math.max(0, contentCount || 0)) + } + + /** The disc a cell of `m` contents occupies at the packing density. */ + function spreadForCell(memberCount) { + return territoryRadius(memberCount) + } + + /** + * The zoom ceiling: the scale at which a card holds a full excerpt. + * + * This is what replaces "a content is never wider than 56 px". That rule + * was right about a DISC -- text does not scale with the camera, so + * magnifying a circle buys nothing. It is the wrong rule for a card, + * whose point is the text: the ceiling has to be where the text is + * legible, which is a measurable quantity rather than a taste. + */ + function zoomCeiling() { + return CARD_FULL_PX / CARD_H + } + + /** + * What a content's mark can show at this scale. + * + * There are no tiers and no cross-fades. The mark is always a card; how + * much fits is a function of how big it is, and lines appear one at a + * time as the room arrives. That is simpler than the three smoothstep + * ramps it replaces, and it is continuous by construction. + * + * `round` is how far the mark has morphed from a circle to a rectangle: + * 1 is a circle (corner radius = half the shorter side), 0 is the card. + * + * @param scale CSS px per content radius + * @return {w, h, round, lines, charWidth, showTitle, showParent, summaryLines} + */ + function cardFor(scale) { + var k = scale > 0 ? scale : 0 + var w = CARD_W * k + var h = CARD_H * k + // The circle holds on while the mark is too small for any text, then + // gives way over the range where the first line becomes readable. + var round = 1 - smoothstep(20, 42, k) + var usableH = h - CARD_PAD * 2 + var usableW = w - CARD_PAD * 2 + var lines = usableH > 0 ? Math.floor(usableH / CARD_LINE_PX) : 0 + return { + w: w, + h: h, + round: round, + usableW: usableW, + lines: lines, + // Title first, then where it lives, then the excerpt. Dropping + // from the bottom keeps the most identifying line longest. + showTitle: lines >= 1 && usableW >= 40, + titleLines: lines >= 3 ? 2 : 1, + showParent: lines >= 4 && usableW >= 80, + summaryLines: lines >= 5 ? lines - 3 : 0, + } + } + + /** + * The child groups as territories: absolute radii, tangent, disjoint. + * + * Nothing is scaled to fit. The LEVEL is sized to hold the pack (see + * levelRadiusFor), not the pack squeezed to fit the level, which is what + * makes the packSiblings guarantee -- mutually tangent, never + * overlapping -- survive all the way to the screen. + * + * That is only possible because a content shared by several groups is + * DRAWN IN EACH of them. Keeping one mark per content forces the shared + * mark to sit between its groups, which stretches both outlines toward + * it; with a hundred groups every outline crosses every other and the + * level reads as a hairball. Measured on the root: 5565 outline pairs, + * all of them crossing, against zero once the mark is duplicated. + * + * @param children [{tag, reach}] + */ + function layoutAnchors(children) { + var result = { anchors: [], byTag: {}, packExtent: 0 } + if (!children || children.length === 0) return result + + var radii = children.map(function (child) { + // A group in the list has at least the one content that put it + // there; a zero radius would make the packing degenerate. + return territoryRadius(Math.max(1, child.reach || 0)) + }) + var packed = packSiblings(radii) + var bounds = enclose(packed) + result.packExtent = bounds.r + + for (var i = 0; i < packed.length; i++) { + var anchor = { + tag: children[i].tag, + reach: Math.max(1, children[i].reach || 0), + index: i, + x: packed[i].x - bounds.x, + y: packed[i].y - bounds.y, + r: packed[i].r, + } + result.anchors.push(anchor) + result.byTag[anchor.tag] = anchor + } + return result + } + + /** + * The radius of a level that has to hold this pack and this many direct + * contents. + * + * Derived from the pack rather than from the level's content count: with + * a content drawn in each of its groups a level shows more marks than it + * has contents, and the room it needs is the room the marks need. + * INNER_FILL leaves the ring outside the pack for the direct contents. + */ + function levelRadiusFor(packExtent, directCount) { + return Math.max(packExtent, territoryRadius(directCount || 0)) / INNER_FILL + } + + /** + * One mark per (content, group) pair, each inside its own group. + * + * A content in three groups gets three marks. That is a deliberate + * reversal: the partition that gave every content exactly one mark also + * forced shared marks between their groups, and the resulting overlap + * made a level of a hundred groups unreadable. Identity is kept in the + * `key` instead of in the geometry, so the instances of one content can + * be tied together on demand rather than always. + * + * Needs no relaxation: within a group the sunflower is at least 1.02 + * CONTENT_PITCH apart at every size, and the groups are disjoint, so two + * marks can never overlap. + * + * @param memberships [[key, [groupIndex, ...]], ...] in payload order + * @param anchors from layoutAnchors + * @param options {levelRadius} scales the band the group-less marks ring + * @return [{key, content, group, x, y, inBand}] + */ + function placeMarks(memberships, anchors, options) { + options = options || {} + var levelR = options.levelRadius === undefined ? 1 : options.levelRadius + var rows = memberships || [] + var byGroup = [] + for (var g = 0; g < anchors.length; g++) byGroup.push([]) + var bandBound = [] + + for (var i = 0; i < rows.length; i++) { + var groups = rows[i][1] || [] + var known = 0 + for (var k = 0; k < groups.length; k++) { + var g = groups[k] + if (!anchors[g]) continue + // One mark per (content, group), so a group named twice in a + // row still yields one mark. Otherwise the guarantee would + // depend on the manifest never repeating itself. + if (groups.indexOf(g) !== k) continue + byGroup[g].push(i) + known++ + } + // No group of this level: either it carries no child tag at all, + // or its group is past the response's cap. Both ring the band. + if (known === 0) bandBound.push(i) + } + + var marks = [] + for (var a = 0; a < anchors.length; a++) { + var anchor = anchors[a] + var members = byGroup[a] + // The marks occupy the room the marks need, which is less than + // the territory whenever fewer contents are known than the + // group's reach. The shape then states what is known; the label + // still states the count. + // + // Capped by the territory. On consistent data the cap never + // binds: a group holds at most `reach` contents, so its marks + // need at most territoryRadius(reach). It binds only when the + // manifest and coTags disagree -- a truncated list, a cache + // straddling two schemas -- and then the group crowds, which is + // truthful, instead of spilling over its neighbours. + var spread = Math.min(spreadForCell(members.length), anchor.r) + for (var m = 0; m < members.length; m++) { + var local = insideSlot(m, members.length) + marks.push({ + key: rows[members[m]][0], + content: members[m], + group: a, + x: anchor.x + local.x * spread, + y: anchor.y + local.y * spread, + inBand: false, + }) + } + } + + var band = layoutBand(bandBound.map(function (index) { + return { url: String(index) } + })) + for (var b = 0; b < bandBound.length; b++) { + marks.push({ + key: rows[bandBound[b]][0], + content: bandBound[b], + group: -1, + x: band[b].x * levelR, + y: band[b].y * levelR, + inBand: true, + }) + } + return marks + } + + /** + * Where an ancestor's field sits in the current level's space. + * + * Entering a group dilates its parent's field about that group's centre + * by the BLOOM -- the ratio between the level the group becomes and the + * circle it was, about 2.2 on real data. So the chain is + * + * q = p * scale + offset, scale = product of the blooms + * + * and a point maps through it while a TERRITORY RADIUS does not: one + * circle grew (the one entered, into the level you are now in) and the + * rest were pushed apart without changing size, because a radius states a + * content count and must not change because the reader navigated. A + * level BOUNDARY does scale, since it has to keep containing the field + * that spread inside it. + * + * Why a dilation rather than re-packing with one radius enlarged: measured + * on a 24-group level, re-packing moves siblings up to 1.5 level radii and + * pulls up to 11 of 23 INWARD -- a scatter, not a transition. A dilation + * can only increase distance from its centre, so nothing moves inward, + * nothing is overtaken and no new overlap appears. + * + * The bloom is bounded, so depth 5 composes to about 50 rather than the + * thousands the renormalised model reached. A step with no bloom + * truncates the chain rather than inventing a position. + * + * @param steps [{x, y, beta}] nearest ancestor first; x,y is the entered + * circle's centre in THAT ancestor's own coordinates + */ + // Angular resolution of a level's boundary. 96 sectors is under 4 degrees + // apiece -- fine enough that the outline reads as a curve, coarse enough + // that a lone mark makes a bump rather than a spike. + var HULL_SECTORS = 96 + // Circular smoothing passes over the radii, and a floor as a share of + // the hull's own peak. Both tuned by measurement: with no floor a sparse + // level collapsed to a starfish (radius varying by 0.97 of its peak on a + // 25-mark level), and with more smoothing than this the shape rounds off + // into the circle it was supposed to stop being. At 0.60 and 8 passes the + // radius varies by 0.08 on the root, 0.31 on /Arduino, 0.37 on /OS -- + // organic at every density, spiky at none. + var HULL_SMOOTH = 8 + var HULL_FLOOR = 0.60 + // Clearance beyond the furthest mark in a sector, in content radii: the + // mark's own radius plus a gutter, so the outline never grazes a disc. + var HULL_PAD = 2.2 + + /** + * How far a level's contents reach, per direction: its boundary. + * + * NOT a metaball, and that is the point. A single density field over a + * whole level cannot be sampled finely enough to be trusted: measured on + * the root, a level-wide contour on the 25x25 grid left 135 of 411 marks + * OUTSIDE its own outline, and reported anywhere from 1 to 34 separate + * bodies as the kernel changed -- grid aliasing, not geometry. Per-group + * contours escape this because each is sampled over its own bounds. + * + * A radial hull has none of those failure modes. It contains every mark + * by construction (each sector is pushed out past its furthest mark), it + * is one closed body by construction, it costs a pass over the marks + * rather than a grid, and being star-shaped about the centre it can be + * interpolated with another hull angle by angle -- which is what makes + * entering a level a morph rather than a cut. + * + * @param points [{x, y}] in the level's own coordinates + * @param minRadius a floor, so a level with one mark still has a shape + * @return radii by sector, HULL_SECTORS of them, starting at angle 0 + */ + function radialHull(points, minRadius) { + var radii = new Float64Array(HULL_SECTORS) + var floor = Math.max(0, minRadius || 0) + for (var i = 0; i < HULL_SECTORS; i++) radii[i] = floor + + for (var p = 0; p < (points || []).length; p++) { + var point = points[p] + var reach = Math.hypot(point.x, point.y) + var distance = reach + HULL_PAD + var angle = Math.atan2(point.y, point.x) + if (angle < 0) angle += TAU + var sector = Math.floor((angle / TAU) * HULL_SECTORS) % HULL_SECTORS + // A mark has width, so it pushes the outline out over an ARC -- + // and how wide that arc is depends on how far out the mark is. + // A fixed three sectors was wrong at both ends: too wide for a + // mark near the rim and too narrow for one near the middle. The + // half-width of what is DRAWN (half a card) subtends this angle + // at this distance, so the sectors a mark's own shape occupies + // are exactly the ones it pushes. + // + // Measured before the fix: on a 3-group level one card corner of + // 60 hung 1.34 units outside the boundary, because it fell two + // sectors from its mark and only one was pushed. The disc it + // replaced stayed inside, which is why this went unnoticed. + var half = reach > 1e-9 ? Math.atan2(CARD_W / 2, reach) : Math.PI + var span = Math.max(1, Math.ceil((half / TAU) * HULL_SECTORS)) + for (var d = -span; d <= span; d++) { + var s = (sector + d + HULL_SECTORS) % HULL_SECTORS + if (radii[s] < distance) radii[s] = distance + } + } + + // A floor relative to the hull's own reach, so an empty direction + // dents the outline instead of cutting it to the centre. + var peak = 0 + for (var m = 0; m < HULL_SECTORS; m++) if (radii[m] > peak) peak = radii[m] + var relative = peak * HULL_FLOOR + for (var n = 0; n < HULL_SECTORS; n++) { + if (radii[n] < relative) radii[n] = relative + } + + // Circular smoothing, so the outline is a curve rather than a comb. + // Never below what a sector needs, or smoothing would cut a mark out. + var floors = radii.slice() + for (var pass = 0; pass < HULL_SMOOTH; pass++) { + var next = new Float64Array(HULL_SECTORS) + for (var k = 0; k < HULL_SECTORS; k++) { + var a = radii[(k - 1 + HULL_SECTORS) % HULL_SECTORS] + var b = radii[k] + var c = radii[(k + 1) % HULL_SECTORS] + next[k] = Math.max(floors[k], (a + 2 * b + c) / 4) + } + radii = next + } + return radii + } + + /** The same representation, taken off an existing outline's vertices. */ + function radialHullOfRings(rings, centreX, centreY) { + var points = [] + var cx = centreX || 0, cy = centreY || 0 + ;(rings || []).forEach(function (ring) { + for (var i = 0; i < ring.length; i++) { + points.push({ x: ring[i].x - cx, y: ring[i].y - cy }) + } + }) + // The vertices already sit ON the outline, so no extra clearance. + var radii = radialHull(points, 0) + for (var k = 0; k < radii.length; k++) radii[k] -= HULL_PAD + return radii + } + + /** + * Two hulls blended angle by angle: a boundary in mid-morph. + * + * The ends are returned exactly rather than computed. `a + (b-a)*1` is + * not bitwise `b` for every value -- measured, 4 of 96 sectors drifted by + * 2e-15 -- and the settled boundary of a level should BE that level's own + * hull, not a copy of it that a later comparison finds unequal. + * + * Out of range clamps rather than extrapolating, so an easing that + * overshoots cannot turn a shape inside out. + */ + function blendHulls(from, to, t) { + if (!(t > 0)) return from || to + if (t >= 1) return to || from + var out = new Float64Array(HULL_SECTORS) + for (var i = 0; i < HULL_SECTORS; i++) { + var a = from ? from[i] : 0 + var b = to ? to[i] : 0 + out[i] = a + (b - a) * t + } + return out + } + + /** + * The hull's radius in the direction of (x, y), measured from its centre. + * + * The inverse of hullRing: given a point, which sector's radius decides + * where the outline runs there. hullRing puts a sector's radius at the + * MIDDLE of its arc, so the sector holding an angle is simply the floor + * of it -- dropping that and rounding to the nearest VERTEX instead is + * off by one everywhere, which scale.test.js catches. + */ + function hullRadiusAt(radii, x, y) { + if (!radii || radii.length === 0) return null + var n = radii.length + var angle = Math.atan2(y, x) + if (angle < 0) angle += TAU + return radii[Math.floor((angle / TAU) * n) % n] + } + + /** A hull as a closed ring of points, ready to stroke. */ + function hullRing(radii, centreX, centreY) { + var ring = [] + var cx = centreX || 0, cy = centreY || 0 + for (var i = 0; i < HULL_SECTORS; i++) { + // Sector centres, so a radius describes the middle of its arc. + var angle = ((i + 0.5) / HULL_SECTORS) * TAU + ring.push({ x: cx + Math.cos(angle) * radii[i], y: cy + Math.sin(angle) * radii[i] }) + } + ring.push({ x: ring[0].x, y: ring[0].y }) + return ring + } + + /** + * The camera that keeps the picture still across a level change. + * + * Two things move and the camera has to follow both: the coordinate + * ORIGIN moves to the circle being entered, and that circle BLOOMS into + * the level it becomes (radius r -> beta*r). Screen mapping is + * S(p) = C + (p - cam)*k, and entering rewrites the space as + * q = (p - c)*beta, so + * + * cam' = (cam - c)*beta, k' = k/beta + * + * gives S'(q) = C + ((p-c)beta - (cam-c)beta) * k/beta = C + (p-cam)*k + * for EVERY point, and preserves the entered circle's screen radius too + * (beta*r * k/beta = r*k). The bloom is therefore exactly a camera move: + * the beta in the geometry and the 1/beta in the camera cancel, and what + * reveals the bloom afterwards is the camera easing to the new fit. + * + * This lives here, as arithmetic over numbers, because it was once + * deleted on the mistaken grounds that an absolute scale had made it + * unnecessary. An absolute scale removes the RENORMALISATION, not the + * need to follow a moving origin -- and without it the picture jumped + * 264 px on entering and 118 px on leaving. In the renderer that was + * invisible to `node --test`; here it is not. + * + * @param camera {x, y, scale} + * @param centre the entered circle, in the OUTER level's coordinates + * @param beta the bloom: the level's radius over the circle's + * @return {x, y, scale}, or null when the bloom is not usable + */ + function bloomCamera(camera, centre, beta, direction) { + if (!camera || !centre) return null + if (!(beta > 0) || !isFinite(beta)) return null + if (direction === "out") { + return { + x: camera.x / beta + centre.x, + y: camera.y / beta + centre.y, + scale: camera.scale * beta, + } + } + return { + x: (camera.x - centre.x) * beta, + y: (camera.y - centre.y) * beta, + scale: camera.scale / beta, + } + } + + function composeBloom(steps) { + var scale = 1, x = 0, y = 0 + for (var i = 0; i < (steps || []).length; i++) { + var step = steps[i] + if (!step || !(step.beta > 0)) break + scale *= step.beta + x -= step.x * scale + y -= step.y * scale + } + return { scale: scale, x: x, y: y } + } + + // ---- hashing ---------------------------------------------------------- + + function fnv1a(text) { + var hash = 0x811c9dc5 + for (var i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i) + hash = (hash * 0x01000193) >>> 0 + } + return hash + } + + function clamp01(value) { + return value < 0 ? 0 : (value > 1 ? 1 : value) + } + + // ---- smallest enclosing circle --------------------------------------- + + /** + * An enclosing circle of the given circles, tight enough for layout. + * + * Iterative shrink-wrap from the centroid: repeatedly step the centre + * toward whichever circle sticks out furthest, with a decaying step. A + * fixed iteration count keeps it deterministic, and the final radius is + * measured (not estimated), so containment is exact even if the circle is + * a hair larger than the true minimum. Welzl's algorithm would be minimal + * but needs a shuffle, and minimality buys nothing here. + */ + function enclose(circles) { + if (circles.length === 0) return { x: 0, y: 0, r: 0 } + if (circles.length === 1) { + return { x: circles[0].x, y: circles[0].y, r: circles[0].r } + } + + var cx = 0, cy = 0 + circles.forEach(function (c) { cx += c.x; cy += c.y }) + cx /= circles.length + cy /= circles.length + + var farthest = function (x, y) { + var best = circles[0], bestReach = -Infinity + for (var i = 0; i < circles.length; i++) { + var reach = Math.hypot(circles[i].x - x, circles[i].y - y) + circles[i].r + if (reach > bestReach) { bestReach = reach; best = circles[i] } + } + return { circle: best, reach: bestReach } + } + + var step = 0.5 + for (var iteration = 0; iteration < 96; iteration++) { + var far = farthest(cx, cy) + var dx = far.circle.x - cx, dy = far.circle.y - cy + var distance = Math.hypot(dx, dy) + if (distance < EPSILON) break + cx += (dx / distance) * step * far.reach * 0.5 + cy += (dy / distance) * step * far.reach * 0.5 + step *= 0.9 + } + + return { x: cx, y: cy, r: farthest(cx, cy).reach } + } + + // ---- sibling packing -------------------------------------------------- + + // Place circle c tangent to both a and b. + function placeTangent(a, b, c) { + var dx = b.x - a.x, dy = b.y - a.y + var d2 = dx * dx + dy * dy + if (d2 === 0) { + c.x = a.x + a.r + c.r + c.y = a.y + return + } + var a2 = (a.r + c.r) * (a.r + c.r) + var b2 = (b.r + c.r) * (b.r + c.r) + if (a2 > b2) { + var xb = (d2 + b2 - a2) / (2 * d2) + var yb = Math.sqrt(Math.max(0, b2 / d2 - xb * xb)) + c.x = b.x - xb * dx - yb * dy + c.y = b.y - xb * dy + yb * dx + } else { + var xa = (d2 + a2 - b2) / (2 * d2) + var ya = Math.sqrt(Math.max(0, a2 / d2 - xa * xa)) + c.x = a.x + xa * dx - ya * dy + c.y = a.y + xa * dy + ya * dx + } + } + + function overlaps(a, b) { + var reach = a.r + b.r - 1e-6 + var dx = b.x - a.x, dy = b.y - a.y + return reach > 0 && reach * reach > dx * dx + dy * dy + } + + function chainScore(node) { + var a = node.circle, b = node.next.circle + var ab = a.r + b.r + var dx = (a.x * b.r + b.x * a.r) / ab + var dy = (a.y * b.r + b.y * a.r) / ab + return dx * dx + dy * dy + } + + /** + * Pack circles of the given radii around the origin, tangentially and + * without overlap. Front-chain algorithm (the one d3-hierarchy's + * packSiblings uses, ISC-licensed by Mike Bostock; reimplemented here + * because the project ships no bundler and d3 was dropped). + * + * Non-overlap holds BY CONSTRUCTION -- there is no iteration budget that + * can expire. The previous design pushed circles apart for at most 120 + * tries and then let them stack, which for a busy tag meant a pile. + * + * @param radii number[] in the order they should be placed (largest first + * gives the tightest result) + * @return [{x, y, r}] in the same order as `radii` + */ + function packSiblings(radii) { + var circles = radii.map(function (r) { return { x: 0, y: 0, r: r } }) + var n = circles.length + if (n === 0) return circles + + circles[0].x = 0 + circles[0].y = 0 + if (n === 1) return circles + + circles[0].x = -circles[1].r + circles[1].x = circles[0].r + circles[1].y = 0 + if (n === 2) return circles + + placeTangent(circles[1], circles[0], circles[2]) + + var a = { circle: circles[0] } + var b = { circle: circles[1] } + var c = { circle: circles[2] } + a.next = c.previous = b + b.next = a.previous = c + c.next = b.previous = a + + pack: for (var i = 3; i < n; ++i) { + placeTangent(a.circle, b.circle, circles[i]) + c = { circle: circles[i] } + + // Walk outward from the insertion point in both directions, + // taking the nearer side first; on a collision, drop the chain + // back to the offender and retry this circle. + var j = b.next, k = a.previous + var sj = b.circle.r, sk = a.circle.r + do { + if (sj <= sk) { + if (overlaps(j.circle, c.circle)) { + b = j + a.next = b + b.previous = a + --i + continue pack + } + sj += j.circle.r + j = j.next + } else { + if (overlaps(k.circle, c.circle)) { + a = k + a.next = b + b.previous = a + --i + continue pack + } + sk += k.circle.r + k = k.previous + } + } while (j !== k.next) + + c.previous = a + c.next = b + a.next = b.previous = b = c + + // Re-anchor the chain at the pair closest to the centroid. + var best = chainScore(a) + while ((c = c.next) !== b) { + var score = chainScore(c) + if (score < best) { a = c; best = score } + } + b = a.next + } + + return circles + } + + // ---- children inside a parent ---------------------------------------- + + /** + * Lay the parent's children out as slots inside it. + * + * Radii come from the DATA first and the whole pack is then scaled to the + * inner disc -- not the other way round. Sizing each child as a fraction + * of the parent (as the previous design did) makes the packing density + * scale-invariant, so growing the parent buys no room and 200 children + * need more than twice the area available. + * + * @param children [{tag, weight}] weight = the count this circle's label + * will state. Order decides packing order; pass the server's order + * (count desc, natural-order tie-break) so the result is reproducible. + * @return {slots: [{tag, x, y, r, index, weight}], byTag: {tag: slot}} + */ + /** + * Reorders groups so that ones sharing contents are packed next to each + * other. + * + * packSiblings places circles in the order it is given, so the order IS + * the adjacency. Ordering purely by count -- as before -- scatters + * sharing partners across the field, and a content shared between two + * distant groups then has to sit halfway between them, far outside both. + * Measured on the real corpus: a point 25x its group's radius away from + * it. A greedy chain keeps partners together, so a shared content lands + * between neighbours instead of in the void. + * + * Sizes are untouched: this decides only WHO SITS BESIDE WHOM. + * + * @param children [{tag, weight}] in the server's order (count desc) + * @param sharing {tag: {otherTag: sharedCount}} may be absent + * @return the same children, reordered + */ + function orderBySharing(children, sharing) { + if (!sharing || children.length < 3) return children + var remaining = children.slice() + var ordered = [remaining.shift()] // the biggest group anchors the chain + var placed = {} + placed[ordered[0].tag] = true + while (remaining.length > 0) { + var bestIndex = 0, bestScore = -1 + for (var i = 0; i < remaining.length; i++) { + var shared = sharing[remaining[i].tag] || {} + var score = 0 + for (var tag in shared) { + if (placed[tag]) score += shared[tag] + } + // Ties keep the server's order, which is count desc -- so with + // no sharing at all this is exactly the previous behaviour. + if (score > bestScore) { bestScore = score; bestIndex = i } + } + var next = remaining.splice(bestIndex, 1)[0] + placed[next.tag] = true + ordered.push(next) + } + return ordered + } + + /** + * The circle for one path segment inside its parent's layout. A single + * tag is its own slot; an OR segment is ONE circle -- the enclosing + * circle of its members, so the circle you get after fusing A and B + * visibly contains both circles you just dragged together. + * + * Returns null when a tag is absent from the parent's layout (a truncated + * coTag list, or a hand-typed URL). Callers must render the level without + * ancestors rather than invent a slot. + */ + function slotForSegment(layout, segment) { + if (!layout || !segment || segment.length === 0) return null + var members = [] + for (var i = 0; i < segment.length; i++) { + var slot = layout.byTag[segment[i]] + if (!slot) return null + members.push(slot) + } + if (members.length === 1) return members[0] + var fused = enclose(members) + return { + tag: segment.join(","), + members: segment.slice(), + x: fused.x, + y: fused.y, + r: fused.r * FUSE_PAD, + index: members[0].index, + weight: 0, + } + } + + // ---- the free band ---------------------------------------------------- + + /** + * Positions for direct contents, on rings just inside the boundary. + * + * Both terms depend only on the index, so layoutBand(10) is an exact + * element-wise prefix of layoutBand(30): appending a page cannot move a + * dot that is already on screen. The previous design derived each angle + * from the current total, so paging re-scrambled every existing dot. + * + * @param items [{url}] used only for a deterministic per-item jitter + */ + function layoutBand(items) { + return (items || []).map(function (item, index) { + var seed = fnv1a(String(item && item.url ? item.url : index)) + var ring = BAND_RINGS[index % BAND_RINGS.length] + var angle = index * GOLDEN_ANGLE + ((seed % 100) / 100) * 0.02 + return { + index: index, + angle: angle, + x: Math.cos(angle) * ring, + y: Math.sin(angle) * ring, + r: ring, + } + }) + } + + /** + * Positions for contents shown INSIDE a circle (a child group whose few + * contents are worth showing in place rather than making the reader + * enter it). Returned in the circle's own unit space, packed within + * INSIDE_FILL so they stay clear of its outline. + * + * Unlike layoutBand this is NOT prefix-stable: the ring radii depend on + * the count, so the whole arrangement changes when the count does. That + * is fine here and nowhere else -- this population is never paged. It is + * requested in one go, only when it is small enough to be worth showing + * in place at all. + */ + function layoutInside(count, capacity) { + var positions = [] + if (count <= 0) return positions + var slots = capacity === undefined ? count : capacity + for (var i = 0; i < count; i++) { + positions.push(insideSlot(i, slots)) + } + return positions + } + + /** + * One slot of the sunflower, parameterised by the CAPACITY it is laid out + * for rather than by how many are filled. + * + * Splitting capacity from count is what makes the anonymous marks exact: + * a group's marks are drawn for `capacity = count` before any content is + * known, and when the real contents arrive a content that belongs to this + * group alone lands on `insideSlot(itsIndex, count)` — the very slot its + * stand-in occupied. Nothing jumps. Only the contents that turn out to be + * SHARED move, and that movement is itself the information. + */ + function insideSlot(index, capacity) { + var slots = Math.max(1, capacity) + if (slots === 1) return { index: index, x: 0, y: 0 } + // Sunflower packing: even area coverage without a preferred axis. + var radius = INSIDE_FILL * Math.sqrt((index + 0.5) / slots) + var angle = index * GOLDEN_ANGLE + return { + index: index, + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + } + } + + + /** + * Stand-ins for a group whose contents are not known yet: `count` marks + * on the same sunflower the real contents will use. + * + * They are drawn as anonymous — no label, not tappable — because that is + * what they are. What makes them honest is the count: "N things are here" + * is a fact, and each mark is one of them. What makes them harmless is + * insideSlot's capacity parameter: when the real data lands, a content + * belonging to this group alone occupies exactly the mark it replaces. + */ + function anonymousMarks(count, slot, limit) { + var marks = [] + var shown = Math.min(count, limit || count) + if (shown <= 0) return marks + // Mass is preserved when capped, so a 500-count group still reads as + // big instead of collapsing to the cap. + var weight = count / shown + // The SAME spread placeMarks() uses, cap included, so a mark and the + // content it resolves into occupy the same point and nothing jumps + // when the bodies arrive. Changing one without the other breaks that + // silently -- the marks simply land somewhere else. + var spread = Math.min(spreadForCell(count), slot.r) + for (var i = 0; i < shown; i++) { + var local = insideSlot(i, count) + marks.push({ + index: i, + x: slot.x + local.x * spread, + y: slot.y + local.y * spread, + w: weight, + anonymous: true, + }) + } + return marks + } + + // ---- density field and its contour ------------------------------------ + // + // A group's shape is the isoline of a scalar field over ITS OWN points, so + // the outline follows what is inside rather than being a circle decided in + // advance. Two groups merge only by literally sharing a point: the field + // is per-group, so mere adjacency cannot fuse anything -- which matters + // because packSiblings makes every sibling mutually TANGENT, and a single + // global field would melt every level into one blob and claim a + // relationship the data does not contain. + + /** + * Wyvill soft-object kernel, in squared distance to avoid a sqrt. + * Compact support is not an optimisation but a determinism property: a + * point beyond `radius` contributes exactly zero, so a group's contour + * cannot depend on anything outside it. + */ + function kernel(distanceSquared, radiusSquared) { + if (distanceSquared >= radiusSquared) return 0 + var t = 1 - distanceSquared / radiusSquared + return t * t * t + } + + /** + * The kernel radius at which ONE point's isoline is a circle of exactly + * `territoryRadius`. Solving (1 - d²/R²)³ = T for d = territoryRadius. + * + * This is what makes the degenerate case free: a group with no known + * contents is a single point and draws precisely the circle the previous + * design drew, so nothing regresses where there is no data to shape it. + */ + function kernelRadius(territoryRadius, memberCount) { + var solo = territoryRadius / Math.sqrt(1 - Math.cbrt(FIELD_T)) + var m = Math.max(1, memberCount || 1) + // A lone point must reproduce the circle EXACTLY -- that is the + // invariant that makes "no contents known" cost nothing. The spacing + // factor only applies once there is more than one point to space. + if (m === 1) return solo + // Shrink with density so a crowded group reads as one body rather + // than a bag of separate beads. + return solo * KERNEL_SPACING / Math.sqrt(m) + } + + /** + * The kernel a group's ACTUAL points need, so its outline reaches them. + * + * The territory-derived radius assumes the members sit inside the + * territory. A content shared with a distant group does not: it is pulled + * toward that group, and a kernel sized for the territory cannot reach + * it, leaving the point outside its own outline. Sizing from the spread + * of the points that are really there keeps "the shape follows the + * contents" true even when the contents are far-flung. + */ + function kernelForPoints(points, territoryRadius) { + var nominal = kernelRadius(territoryRadius, points.length) + if (points.length < 2) return nominal + // Every k-th point, so the O(m²) scan stays bounded on a large group. + // By index, not by position, so the sample is the same every time. + var step = Math.max(1, Math.ceil(points.length / KERNEL_SAMPLE_MAX)) + var sample = [] + for (var s = 0; s < points.length; s += step) sample.push(points[s]) + if (sample.length < 2) sample = points.slice(0, 2) + + // Mean nearest-neighbour distance: the scale at which these points + // read as one body. + var total = 0 + for (var i = 0; i < sample.length; i++) { + var nearest = Infinity + for (var j = 0; j < sample.length; j++) { + if (i === j) continue + var d = Math.hypot(sample[i].x - sample[j].x, sample[i].y - sample[j].y) + if (d < nearest) nearest = d + } + total += nearest === Infinity ? 0 : nearest + } + var spread = (total / sample.length) * KERNEL_SPACING / Math.sqrt(1 - Math.cbrt(FIELD_T)) + return Math.max(nominal, spread) + } + + /** + * Bounded union of the points' kernels: 1 - Π(1 - fᵢ). + * Bounded to [0,1] however many points there are, so a dense group cannot + * swell without limit, and smooth, so nearby points merge. + * + * @param points [{x, y, r, w}] r = kernel radius, w = weight (default 1) + */ + function fieldAt(points, x, y) { + var product = 1 + for (var i = 0; i < points.length; i++) { + var p = points[i] + var dx = x - p.x, dy = y - p.y + var r = p.r + var value = kernel(dx * dx + dy * dy, r * r) + if (value <= 0) continue + var w = p.w === undefined ? 1 : p.w + product *= 1 - Math.min(1, value * w) + if (product <= 0) return 1 + } + return 1 - product + } + + /** + * A square window just big enough to hold every kernel, plus a margin. + * + * Fitting the window to the shape rather than sampling a fixed extent is + * what keeps the accuracy the same for a small group as for a large one: + * the grid always spends its resolution on the shape instead of on empty + * space around it. It depends only on the points, so it stays + * deterministic. + */ + function fieldBounds(points) { + var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity + for (var i = 0; i < points.length; i++) { + var p = points[i] + if (p.x - p.r < minX) minX = p.x - p.r + if (p.x + p.r > maxX) maxX = p.x + p.r + if (p.y - p.r < minY) minY = p.y - p.r + if (p.y + p.r > maxY) maxY = p.y + p.r + } + var centreX = (minX + maxX) / 2, centreY = (minY + maxY) / 2 + var half = Math.max(maxX - minX, maxY - minY) / 2 + half *= 1.06 // a margin so the isoline never touches the border + return { centreX: centreX, centreY: centreY, half: half } + } + + /** Samples the field on a regular grid. Row-major, y increasing with j. */ + function sampleField(points, bounds, n) { + var values = new Float64Array(n * n) + var step = (2 * bounds.half) / (n - 1) + var originX = bounds.centreX - bounds.half + var originY = bounds.centreY - bounds.half + for (var j = 0; j < n; j++) { + var y = originY + j * step + for (var i = 0; i < n; i++) { + values[j * n + i] = fieldAt(points, originX + i * step, y) + } + } + return { values: values, n: n, originX: originX, originY: originY, step: step } + } + + // Directed segments per marching-squares case, "inside on the left" so + // every ring comes out consistently wound. Edges: 0 bottom, 1 right, + // 2 top, 3 left. Cases 5 and 10 are the ambiguous saddles, resolved + // separately by the value at the cell centre. + var MS_CASES = [ + [], // 0 none inside + [[0, 3]], // 1 BL + [[1, 0]], // 2 BR + [[1, 3]], // 3 BL BR + [[2, 1]], // 4 TR + null, // 5 BL TR (saddle) + [[2, 0]], // 6 BR TR + [[2, 3]], // 7 BL BR TR + [[3, 2]], // 8 TL + [[0, 2]], // 9 BL TL + null, // 10 BR TL (saddle) + [[1, 2]], // 11 BL BR TL + [[3, 1]], // 12 TR TL + [[0, 1]], // 13 BL TR TL + [[3, 0]], // 14 BR TR TL + [], // 15 all inside + ] + + /** + * Marching squares over a sampled field, returning closed rings. + * + * Crossings are identified by the EDGE they lie on, not by their + * coordinates: two neighbouring cells share an edge, so keying on the + * edge makes them the same vertex by integer identity rather than by + * float comparison. That is what makes ring assembly exact. + */ + function marchingSquares(grid, threshold) { + var n = grid.n, values = grid.values, step = grid.step + var inside = function (i, j) { return values[j * n + i] >= threshold } + var coordX = function (i) { return grid.originX + i * step } + var coordY = function (j) { return grid.originY + j * step } + + // Edge ids: horizontal (i,j) = 2*(j*n+i), vertical (i,j) = that + 1. + var vertices = new Map() + var crossing = function (edgeId, i0, j0, i1, j1) { + var found = vertices.get(edgeId) + if (found) return found + var a = values[j0 * n + i0], b = values[j1 * n + i1] + var t = (b === a) ? 0.5 : (threshold - a) / (b - a) + if (t < 0) t = 0 + else if (t > 1) t = 1 + var point = { + x: coordX(i0) + (coordX(i1) - coordX(i0)) * t, + y: coordY(j0) + (coordY(j1) - coordY(j0)) * t, + } + vertices.set(edgeId, point) + return point + } + + var next = new Map() // start edge id -> end edge id + for (var j = 0; j < n - 1; j++) { + for (var i = 0; i < n - 1; i++) { + var code = (inside(i, j) ? 1 : 0) + | (inside(i + 1, j) ? 2 : 0) + | (inside(i + 1, j + 1) ? 4 : 0) + | (inside(i, j + 1) ? 8 : 0) + var pairs = MS_CASES[code] + if (pairs === null) { + // Saddle: the cell centre decides whether the two inside + // corners are connected. A fixed rule, so the topology is + // reproducible rather than dependent on float noise. + var centre = (values[j * n + i] + values[j * n + i + 1] + + values[(j + 1) * n + i + 1] + values[(j + 1) * n + i]) / 4 + var joined = centre >= threshold + if (code === 5) { + pairs = joined ? [[1, 0], [3, 2]] : [[0, 3], [2, 1]] + } else { + pairs = joined ? [[3, 0], [1, 2]] : [[1, 0], [3, 2]] + } + } + if (pairs.length === 0) continue + + var edgeId = function (side) { + if (side === 0) return 2 * (j * n + i) // bottom + if (side === 1) return 2 * (j * n + i + 1) + 1 // right + if (side === 2) return 2 * ((j + 1) * n + i) // top + return 2 * (j * n + i) + 1 // left + } + var ensure = function (side) { + var id = edgeId(side) + if (side === 0) crossing(id, i, j, i + 1, j) + else if (side === 1) crossing(id, i + 1, j, i + 1, j + 1) + else if (side === 2) crossing(id, i, j + 1, i + 1, j + 1) + else crossing(id, i, j, i, j + 1) + return id + } + for (var p = 0; p < pairs.length; p++) { + next.set(ensure(pairs[p][0]), ensure(pairs[p][1])) + } + } + } + + // Walk the links into closed rings. Deterministic: the start order is + // insertion order, which is the fixed cell scan order above. + var rings = [] + var visited = new Set() + next.forEach(function (_end, start) { + if (visited.has(start)) return + var ring = [] + var edge = start + while (edge !== undefined && !visited.has(edge)) { + visited.add(edge) + ring.push(vertices.get(edge)) + edge = next.get(edge) + } + if (ring.length >= 3) { + ring.push(ring[0]) // closed + rings.push(ring) + } + }) + return rings + } + + /** Signed area (shoelace). Positive = counter-clockwise. */ + function polygonArea(ring) { + var sum = 0 + for (var i = 0; i < ring.length - 1; i++) { + sum += ring[i].x * ring[i + 1].y - ring[i + 1].x * ring[i].y + } + return sum / 2 + } + + /** + * A segment from (x0,y0) toward (x1,y1), stopped where it first meets an + * axis-aligned rectangle. + * + * Used to run a connector from a mark to the detail card and end it ON + * the card's edge rather than under it. Returns null when the start is + * already inside the rectangle -- there is no line to draw then, and + * drawing one would put ink across the card's own text. + * + * Liang-Barsky against the four slabs. The line is treated as a ray from + * the start, so the answer is the SMALLEST t in [0,1] that enters the + * box; if it never enters, the whole segment is returned. + */ + /** + * Is (x, y) within a half-mark's reach of the viewport? + * + * A mark is drawn as a w x h box centred on the point, so a point up to + * w/2 (h/2) outside the viewport still puts ink on screen. The renderer + * is generous and expands by the FULL w and h -- the point is that + * whatever margin is chosen, every pass that decides something per mark + * must use the SAME one. Two passes with different margins produced + * marks that were drawn and never named: cards out to a card's width, + * bodies only within 60 px, and the gap between them permanently blank. + * + * Here rather than in the renderer for the same reason clipToRect is: + * a rule the drawing depends on belongs where `node --test` can hold it. + */ + function nearRect(x, y, w, h, rect) { + if (!rect) return true + return x >= rect.x - w && x <= rect.x + rect.w + w + && y >= rect.y - h && y <= rect.y + rect.h + h + } + + function clipToRect(x0, y0, x1, y1, rect) { + if (!rect) return { x: x1, y: y1 } + if (x0 >= rect.x && x0 <= rect.x + rect.w + && y0 >= rect.y && y0 <= rect.y + rect.h) return null + var dx = x1 - x0, dy = y1 - y0 + var enter = 0, exit = 1 + var slabs = [ + [-dx, x0 - rect.x], + [dx, rect.x + rect.w - x0], + [-dy, y0 - rect.y], + [dy, rect.y + rect.h - y0], + ] + for (var i = 0; i < 4; i++) { + var p = slabs[i][0], q = slabs[i][1] + if (p === 0) { + // Parallel to this slab: outside it means no crossing at all. + if (q < 0) return { x: x1, y: y1 } + continue + } + var t = q / p + if (p < 0) { if (t > enter) enter = t } + else { if (t < exit) exit = t } + } + if (enter > exit || enter > 1) return { x: x1, y: y1 } + return { x: x0 + dx * enter, y: y0 + dy * enter } + } + + /** + * Where a segment leaves a rectangle, walking from an INSIDE start. + * + * The mirror of clipToRect, for a connector whose far end is off screen: + * the line stops at the viewport edge and a mark goes there instead. + * Returns null when the far end is inside too, i.e. nothing to clamp. + */ + function clampToRect(x0, y0, x1, y1, rect) { + if (!rect) return null + if (x1 >= rect.x && x1 <= rect.x + rect.w + && y1 >= rect.y && y1 <= rect.y + rect.h) return null + var dx = x1 - x0, dy = y1 - y0 + var exit = 1 + var slabs = [ + [-dx, x0 - rect.x], + [dx, rect.x + rect.w - x0], + [-dy, y0 - rect.y], + [dy, rect.y + rect.h - y0], + ] + for (var i = 0; i < 4; i++) { + var p = slabs[i][0], q = slabs[i][1] + if (p === 0) continue + var t = q / p + if (p > 0 && t < exit) exit = t + } + if (!(exit > 0)) exit = 0 + return { x: x0 + dx * exit, y: y0 + dy * exit } + } + + /** Even-odd test across every ring, so holes behave. */ + function pointInPolygon(rings, x, y) { + var inside = false + for (var r = 0; r < rings.length; r++) { + var ring = rings[r] + for (var i = 0, j = ring.length - 2; i < ring.length - 1; j = i++) { + var a = ring[i], b = ring[j] + if ((a.y > y) !== (b.y > y) + && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) { + inside = !inside + } + } + } + return inside + } + + /** One Chaikin pass: takes the corners off a marching-squares ring. */ + function smoothRing(ring) { + if (ring.length < 4) return ring + var out = [] + for (var i = 0; i < ring.length - 1; i++) { + var a = ring[i], b = ring[i + 1] + out.push({ x: a.x * 0.75 + b.x * 0.25, y: a.y * 0.75 + b.y * 0.25 }) + out.push({ x: a.x * 0.25 + b.x * 0.75, y: a.y * 0.25 + b.y * 0.75 }) + } + out.push(out[0]) + return out + } + + /** + * The grid point where the field is strongest — where a group's label + * belongs, and guaranteed inside its own contour. + */ + function fieldArgmax(grid) { + var best = -1, bx = 0, by = 0 + for (var j = 0; j < grid.n; j++) { + for (var i = 0; i < grid.n; i++) { + var v = grid.values[j * grid.n + i] + if (v > best) { + best = v + bx = grid.originX + i * grid.step + by = grid.originY + j * grid.step + } + } + } + return { x: bx, y: by, value: best } + } + + /** + * A group's outline, from its points. Everything is in the group's own + * unit space (its territory is the origin with radius 1). + * + * @param points [{x, y, r, w}] + * @return {rings, area, anchor} — rings smoothed and closed + */ + function contour(points, options) { + options = options || {} + var threshold = options.threshold === undefined ? FIELD_T : options.threshold + var n = options.gridN || GRID_N + if (!points || points.length === 0) return { rings: [], area: 0, anchor: { x: 0, y: 0 } } + + var grid = sampleField(points, options.bounds || fieldBounds(points), n) + var rings = marchingSquares(grid, threshold).map(smoothRing) + var area = 0 + rings.forEach(function (ring) { area += Math.abs(polygonArea(ring)) }) + return { rings: rings, area: area, anchor: fieldArgmax(grid) } + } + + /** Smooth 0→1 ramp, for cross-fading level of detail without steps. */ + function smoothstep(edge0, edge1, x) { + if (edge1 === edge0) return x < edge0 ? 0 : 1 + var t = clamp01((x - edge0) / (edge1 - edge0)) + return t * t * (3 - 2 * t) + } + + return { + TAU: TAU, + GOLDEN_ANGLE: GOLDEN_ANGLE, + INNER_FILL: INNER_FILL, + BAND_RINGS: BAND_RINGS, + CONTENT_R: CONTENT_R, + CONTENT_PITCH: CONTENT_PITCH, + PACK_K: PACK_K, + KERNEL_SPACING: KERNEL_SPACING, + territoryRadius: territoryRadius, + spreadForCell: spreadForCell, + CARD_W: CARD_W, + CARD_H: CARD_H, + CARD_ASPECT: CARD_ASPECT, + CARD_FULL_PX: CARD_FULL_PX, + CARD_PAD: CARD_PAD, + CARD_TITLE_PX: CARD_TITLE_PX, + CARD_BODY_PX: CARD_BODY_PX, + CARD_LINE_PX: CARD_LINE_PX, + cardFor: cardFor, + zoomCeiling: zoomCeiling, + layoutAnchors: layoutAnchors, + HULL_SECTORS: HULL_SECTORS, + radialHull: radialHull, + radialHullOfRings: radialHullOfRings, + blendHulls: blendHulls, + hullRing: hullRing, + hullRadiusAt: hullRadiusAt, + nearRect: nearRect, + clipToRect: clipToRect, + clampToRect: clampToRect, + bloomCamera: bloomCamera, + composeBloom: composeBloom, + FUSE_PAD: FUSE_PAD, + INSIDE_FILL: INSIDE_FILL, + FIELD_T: FIELD_T, + GRID_N: GRID_N, + fieldBounds: fieldBounds, + kernel: kernel, + kernelRadius: kernelRadius, + kernelForPoints: kernelForPoints, + orderBySharing: orderBySharing, + fieldAt: fieldAt, + sampleField: sampleField, + marchingSquares: marchingSquares, + polygonArea: polygonArea, + pointInPolygon: pointInPolygon, + smoothRing: smoothRing, + fieldArgmax: fieldArgmax, + contour: contour, + smoothstep: smoothstep, + fnv1a: fnv1a, + clamp01: clamp01, + enclose: enclose, + packSiblings: packSiblings, + slotForSegment: slotForSegment, + layoutBand: layoutBand, + layoutInside: layoutInside, + insideSlot: insideSlot, + levelRadiusFor: levelRadiusFor, + placeMarks: placeMarks, + anonymousMarks: anonymousMarks, + } +}) diff --git a/Client/TagMap/tagmap.css b/Client/TagMap/tagmap.css new file mode 100644 index 0000000..35f9c4a --- /dev/null +++ b/Client/TagMap/tagmap.css @@ -0,0 +1,603 @@ +/* + * TagMap tokens and chrome (celestial-chart style). + * + * The canvas reads the --tagmap-* custom properties via getComputedStyle; + * the [theme="dark"] attribute (ThemeChanger) swaps the set. The palette is + * validated (dataviz validate_palette.js, both modes, all-pairs + ordinal). + * + * Three roles, three colours, and nothing else gets one: + * + * blue a tag. One tone in three *states* rather than three tones: filled + * with a ring (in the selection), a pale outline (on the map), + * dashed (a name suggestion). Taken from the site's own tag colour + * (#3498db — `.card-item.head.tag`, and --tag-background-color in + * dark). The map used to carry its own #2a78d6/#1c5cab, 11–13° of + * hue away from it. + * pink the content you are READING -- the detail card and the lines + * tying it to every place that content appears. A fourth role, and + * the distinction is real: amber is the segment you chose, pink is + * the content you chose inside it, and the map already draws those + * two classes apart (blue vs grey). It is also the only element + * that has to be followed clear across the screen, which is what + * buys it a hue of its own -- a neutral could only be followed by + * being darker than everything else (5.3:1 against the ground, + * where the pink manages it at 4.0:1). + * + * Measured: the line must beat the mark edges (3.29:1) and the + * nebula outlines (2.48:1) to be followable, and stay under the + * body ink (6.62:1) to stay quiet. #e12885 lands at 3.61 inside a + * nebula, 3.99 over the wash and 4.34 on the card it ends on. + * + * amber the selection — the level you are in, the ancestors you came + * through, the fusion you are previewing, the target you are about + * to drop on. Whatever you have chosen or are choosing. Taken from + * the site's own --content-link-color (hsl(40 75% 48%) = #d6991f), + * which dresses `.selected .card-item` — the same role. + * + * Both hues are the SITE's, and both are one value across the two modes, + * because that is how the site defines them (dark gives its tag colour an + * alpha rather than a new hue, and never overrides the link colour at all). + * + * What the map adds is a RING step per mode, and only because the medium + * differs: the site paints these as card fills and 10 px borders, while the + * map strokes them as hairlines at 17–55% alpha. Measured, the site value + * used as a nebula hairline reaches 1.76:1 where the ring step reaches + * 2.47:1 — so the ring is derived from the base at the SAME oklch hue + * (242.7° and 78.4°, exact), never picked separately. That is the whole + * divergence from the site palette, and it is one step, not one colour. + * paper a content. NOT a hue: a tinted surface with a grey edge, because + * which contents have further structure is already stated by the + * segment outline they sit inside, and 411 of them must not be the + * loudest thing on the map. + * + * The selection used to be amber, and amber is the blue's complement: the 7% + * selection wash under the 10% tag wash cancelled to grey (measured: chroma + * 18 -> 3), which drained the colour out of the whole picture. Pink is not + * blue's complement, so the stack survives (chroma 13 -> 15). + * + * Everything else is ink, hairlines and surfaces — a chart, not a 3D scene. + */ + +html { + /* Light: a star chart on paper. */ + --tagmap-bg: #fcfcfb; + --tagmap-ink: #0b0b0b; + --tagmap-ink-secondary: #52514e; + --tagmap-muted: #898781; + --tagmap-gridline: #e1e0d9; + --tagmap-border: rgba(11, 11, 11, 0.12); + --tagmap-node-surface: #ffffff; + --tagmap-tag: #3498db; /* the site's tag colour, verbatim */ + --tagmap-tag-ring: #006399; /* L 0.48 at the same hue, for hairlines */ + --tagmap-focus: #d6991f; /* the site's link colour, verbatim */ + --tagmap-focus-ring: #a87500; /* L 0.60 at the same hue */ + --tagmap-reading: #e12885; /* the content being read, and its lines */ + /* A content, in three parts, because one flat colour cannot do the two + jobs a mark has. The EDGE says "here is a mark" -- 3.37:1 against the + map, clearing the 3:1 non-text bar. The DISC is what a mark looks like + before it holds any text. The PAPER is what it becomes once it does, + and it has to be light enough to read 11 px type on (7.94:1). + + A single light grey fails as a mark (1.39:1 against the ground) and a + single dark one fails as a page (2.07:1 for the small line), which is + why the fill crosses from disc to paper as the mark squares off -- + measured, the disc has lightened to #ccd4db by the size where a title + first appears, and to plain paper before the excerpt does. + + Kept apart from --tagmap-node-surface, which the DOM chrome also + wears: tinting that would tint the breadcrumb and the info card. */ + --tagmap-content-disc: #bec8d1; + --tagmap-content-paper: #ffffff; + --tagmap-content-edge: #808080; + /* The parallax speck field. On paper the specks are INK rather than + light -- a white speck cannot exist on a white ground -- so the light + value is a dark wash where the dark value is a pale one. Both are + drawn at the same weight the content marks carry, so the field is + always subordinate to them without being invisible: see + buildStarLayer(). The two inks carry the SAME alpha, which is what + makes the two themes one design rather than two. */ + --tagmap-star: rgba(11, 11, 11, 0.50); + --tagmap-shadow: 0 2px 10px rgba(11, 11, 11, 0.10); + --tagmap-accent-wash: rgba(52, 152, 219, 0.12); +} + +[theme="dark"] { + /* Dark: a night star chart. */ + --tagmap-bg: #1a1a19; + --tagmap-ink: #ffffff; + --tagmap-ink-secondary: #c3c2b7; + --tagmap-muted: #898781; + --tagmap-gridline: #2c2c2a; + --tagmap-border: rgba(255, 255, 255, 0.14); + --tagmap-node-surface: #242422; + --tagmap-tag: #3498db; /* the same value: the site does not swap it */ + --tagmap-tag-ring: #46b4ff; /* L 0.74 -- a hairline on dark must be lighter */ + --tagmap-focus: #d6991f; + --tagmap-focus-ring: #f7ae00; /* L 0.80 */ + --tagmap-reading: #f0559e; /* a lighter step, as the rings go */ + --tagmap-content-disc: #3a4149; + --tagmap-content-paper: #242422; + --tagmap-content-edge: #808080; + --tagmap-star: rgba(200, 214, 245, 0.50); + --tagmap-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); + --tagmap-accent-wash: rgba(52, 152, 219, 0.22); +} + +/* --- page frame ---------------------------------------------------------- */ + +body.tagmap-page { + overflow: hidden; + height: 100%; + overscroll-behavior: none; + background: var(--tagmap-bg); +} + +#tagmap-app { + position: fixed; + inset: 0; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + /* The shared site header floats above (its own z-index/position). */ +} + +.tagmap-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + touch-action: none; /* the SPA owns panning/pinching */ + cursor: grab; +} + +.tagmap-canvas:active { + cursor: grabbing; +} + +.tagmap-noscript { + position: fixed; + top: 40%; + width: 100%; + text-align: center; + color: var(--tagmap-ink); +} + +/* --- floating breadcrumb ------------------------------------------------- */ + +.tagmap-breadcrumb { + position: fixed; + top: calc(48px + env(safe-area-inset-top, 0px)); + left: calc(12px + env(safe-area-inset-left, 0px)); + right: calc(12px + env(safe-area-inset-right, 0px)); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4em; + pointer-events: none; /* let pans start between chips */ + z-index: 5; +} + +.tagmap-breadcrumb > * { + pointer-events: auto; +} + +.tagmap-segment { + display: inline-flex; + align-items: center; + gap: 0.25em; + border: 1px solid var(--tagmap-border); + border-radius: 8px; + padding: 0.15em 0.4em; + background: color-mix(in srgb, var(--tagmap-node-surface) 82%, transparent); + box-shadow: var(--tagmap-shadow); + backdrop-filter: blur(6px); +} + +.tagmap-chip { + display: inline-flex; + align-items: center; + gap: 0.3em; + background: var(--tagmap-accent-wash); + border: none; + border-radius: 6px; + padding: 0.28em 0.6em; + font-size: 0.85em; + color: var(--tagmap-ink); + cursor: default; +} + +button.tagmap-chip, +.tagmap-chip-root { + cursor: pointer; +} + +.tagmap-chip-root { + background: color-mix(in srgb, var(--tagmap-node-surface) 82%, transparent); + border: 1px solid var(--tagmap-border); + box-shadow: var(--tagmap-shadow); + backdrop-filter: blur(6px); + color: var(--tagmap-ink); +} + +.tagmap-chip-root.current { + font-weight: 600; +} + +.tagmap-chip-remove, +.tagmap-segment-remove { + border: none; + background: none; + color: var(--tagmap-muted); + cursor: pointer; + padding: 0 0.2em; + font-size: 1em; + line-height: 1; + min-width: 24px; + min-height: 24px; +} + +.tagmap-chip-remove:hover, +.tagmap-segment-remove:hover { + color: var(--tagmap-ink); +} + +.tagmap-separator { + color: var(--tagmap-muted); +} + +/* --- tag popup ----------------------------------------------------------- */ + +.tagmap-popup { + position: fixed; + top: 0; + left: 0; + display: none; + flex-direction: column; + min-width: 190px; + max-width: min(80vw, 320px); + background: var(--tagmap-node-surface); + color: var(--tagmap-ink); + border: 1px solid var(--tagmap-border); + border-radius: 10px; + box-shadow: var(--tagmap-shadow); + padding: 0.35em; + z-index: 20; +} + +.tagmap-popup.visible { + display: flex; +} + +.tagmap-popup-title { + font-weight: 600; + padding: 0.3em 0.5em 0.45em; + word-break: break-all; + border-bottom: 1px solid var(--tagmap-border); + margin-bottom: 0.25em; +} + +.tagmap-popup-action { + display: block; + width: 100%; + min-height: 44px; + text-align: left; + border: none; + border-radius: 7px; + background: none; + color: inherit; + padding: 0.55em 0.7em; + font-size: 0.95em; + cursor: pointer; +} + +.tagmap-popup-action:hover:not(:disabled) { + background: var(--tagmap-accent-wash); +} + +.tagmap-popup-action:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* --- content info card --------------------------------------------------- */ + +.tagmap-info-card { + position: fixed; + left: 50%; + bottom: calc(2em + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%); + display: none; + flex-direction: column; + width: min(92vw, 30em); + /* So the cap below is the height the reader sees. Without it the 1.1em + padding and the border sit outside max-height, and a stated 288 px + ceiling rendered as 324. */ + box-sizing: border-box; + /* A fixed ceiling, not a share of the screen. At 60vh the card grew with + whatever the summary happened to be: measured on a 703 px viewport it + reached 363 px, 52% of the height, and the map it is annotating became + the smaller half. The em cap makes the card a preview of a fixed size + -- the summary already scrolls inside it -- and the vh term is only a + floor guard for short screens. */ + /* A ceiling AND a floor, because only the summary flexes: the title, + the padding and the buttons are 111 px whatever the screen does, so a + share-of-screen cap alone spends the whole shortfall out of the one + part worth reading. Measured at 33vh with no floor -- 600 px of screen + left 4 lines, 500 px left 2, 420 px left 1 and 360 px left none. + + max() puts a stop under it: below about a 640 px viewport the card + stops shrinking with the screen and holds 13em, which is 111 px of + chrome and four lines. It does take a larger SHARE of a short screen, + and that is the trade -- a card that fits the ratio and says nothing + is not cheaper than one that is slightly too big. + + dvh, not vh: on a phone vh is the tall viewport with the URL bar + hidden, so a share of it can exceed what is actually on screen. The vh + line before it is the fallback for browsers without dvh. */ + max-height: max(13em, min(18em, 33vh)); + max-height: max(13em, min(18em, 33dvh)); + /* Translucent, so the mark under the card is still findable: measured, + 27% of taps at the card tier put the card over the very thing that was + tapped, and the reading ring is drawn on the canvas UNDER this. + + The BLUR is the lever here, not the alpha, which is the opposite of + what it looks like. Measured against a 2 px reading ring sitting + behind the card: at blur 10 the ring is invisible even at 68% opacity, + and at blur 2-3 it shows through even at 88%. A 10 px blur averages a + 2 px line away whatever the alpha does. So the alpha stays high enough + to keep the type comfortable -- 19.5:1 light, 15.7:1 dark, against the + 4.5:1 body-text floor -- and the blur comes down to where the map + reads through it. */ + background: color-mix(in srgb, var(--tagmap-node-surface) 50%, transparent); + backdrop-filter: blur(4px); + color: var(--tagmap-ink); + border: 1px solid var(--tagmap-border); + border-radius: 12px; + box-shadow: var(--tagmap-shadow); + padding: 1.1em; + z-index: 20; +} + +.tagmap-info-card.visible { + display: flex; +} + +.tagmap-card-title { + font-weight: 600; + margin-bottom: 0.5em; +} + +/* Marks a content that matched by tag-name similarity, not by a tag. */ +.tagmap-badge { + align-self: flex-start; + font-size: 0.75em; + color: var(--tagmap-ink-secondary); + border: 1px dashed var(--tagmap-border); + border-radius: 5px; + padding: 0.1em 0.5em; + margin-bottom: 0.6em; +} + +/* Short screens: claw back the fixed 111 px, since that is what the summary + is competing with. Padding, the title gap and the button row give up about + 26 px between them, which is another line and a half of summary at the + sizes where lines are scarce. */ +@media (max-height: 640px) { + .tagmap-info-card { + padding: 0.7em; + } + + .tagmap-card-title { + margin-bottom: 0.3em; + } + + .tagmap-card-actions { + margin-top: 0.45em; + } + + .tagmap-card-actions a, + .tagmap-card-actions button { + min-height: 30px; + } +} + +.tagmap-card-summary { + overflow-y: auto; + /* Never sideways. The summary is server-rendered OutlineText and can + carry anything an article can -- a figure, a code block, a table -- so + the rules below make each of those fit or scroll on its own instead of + dragging the whole card wide. Measured before them: a 1283x768 image + arrived at natural size and took the summary to 1323 px of scrollWidth + in a 465 px box, with a horizontal scrollbar and the text pushed out + of view. */ + overflow-x: hidden; + font-size: 0.9em; + color: var(--tagmap-ink-secondary); + min-height: 0; + /* A bare URL is one long word and would overflow on its own. */ + overflow-wrap: anywhere; +} + +/* A figure in a summary is a THUMBNAIL, not an illustration: the card is + here to be read, and an image at illustration size buries the excerpt it + is supposed to accompany. This is the treatment the site already gives a + summary elsewhere (.child-summary figure in ContentsViewer/styles/base.css + -- floated, fixed size, caption hidden), sized for this wider card. + + Note the map does NOT load Client/OutlineText/style.css, whose + `figure img { max-width: 80%; max-height: 300px }` would otherwise have + caught this; loading it here would bring in a stylesheet meant for an + article body onto a page that has none. */ +.tagmap-card-summary figure { + float: left; + clear: left; + margin: 0.2em 0.8em 0.4em 0; + width: 10em; + height: 7.5em; +} + +.tagmap-card-summary figure img { + width: 100%; + height: 100%; + max-width: none; + /* Fill the frame without distorting: a thumbnail of a wide screenshot + and one of a tall diagram have to sit in the same box. */ + object-fit: cover; + border-radius: 4px; +} + +.tagmap-card-summary figcaption { + display: none; +} + +/* Anything else that carries its own width scrolls inside itself. */ +.tagmap-card-summary img, +.tagmap-card-summary video, +.tagmap-card-summary svg, +.tagmap-card-summary iframe { + max-width: 100%; + height: auto; +} + +.tagmap-card-summary pre, +.tagmap-card-summary table { + max-width: 100%; + overflow-x: auto; +} + +.tagmap-card-actions { + display: flex; + gap: 0.5em; + justify-content: flex-end; + margin-top: 0.7em; +} + +/* Compact, because the row is chrome inside a card whose whole height is + capped at 18em -- 54 px of buttons was a fifth of it, spent on two words. + + 34 px is below the 44 pt Apple asks for and the 48 dp Material asks for, + and well above the 24 x 24 WCAG 2.5.8 requires. The card is not the only + way out either: Escape closes it, tapping the same mark closes it, and + tapping empty space closes it -- so the target that has to be reliable is + the Open link, which is the wider of the two. */ +.tagmap-card-actions a, +.tagmap-card-actions button { + min-height: 34px; + font-size: 0.9em; + display: inline-flex; + align-items: center; + padding: 0 0.85em; + border: 1px solid var(--tagmap-border); + border-radius: 7px; + background: none; + color: inherit; + text-decoration: none; + cursor: pointer; +} + +.tagmap-card-actions a { + background: var(--tagmap-accent-wash); + font-weight: 600; +} + +/* --- loading / note / toast ---------------------------------------------- */ + +/* + * Loading never blocks: a 2px line slides along the top edge while a request + * is in flight, and the map stays fully interactive underneath. + */ +.tagmap-progress { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 100%; + opacity: 0; + transition: opacity 0.2s; + background: linear-gradient(90deg, + transparent 0%, + var(--tagmap-tag) 45%, + var(--tagmap-tag) 55%, + transparent 100%); + background-size: 45% 100%; + background-repeat: no-repeat; + animation: tagmap-progress 1.1s ease-in-out infinite; + pointer-events: none; + z-index: 40; +} + +.tagmap-progress.visible { + opacity: 1; +} + +@keyframes tagmap-progress { + from { background-position: -45% 0; } + to { background-position: 145% 0; } +} + +.tagmap-field-note { + position: fixed; + right: calc(0.9em + env(safe-area-inset-right, 0px)); + bottom: calc(0.7em + env(safe-area-inset-bottom, 0px)); + font-size: 0.75em; + color: var(--tagmap-muted); + pointer-events: none; + z-index: 5; +} + +.tagmap-toast { + position: fixed; + left: 50%; + bottom: calc(1.5em + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%) translateY(1em); + background: var(--tagmap-node-surface); + color: var(--tagmap-ink); + border: 1px solid var(--tagmap-border); + border-radius: 10px; + box-shadow: var(--tagmap-shadow); + padding: 0.6em 1em; + display: flex; + gap: 0.8em; + align-items: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s, transform 0.25s; + z-index: 30; +} + +.tagmap-toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); + pointer-events: auto; +} + +.tagmap-toast button { + border: 1px solid var(--tagmap-border); + border-radius: 6px; + background: none; + color: inherit; + padding: 0.25em 0.7em; + cursor: pointer; +} + +/* --- accessibility ------------------------------------------------------- */ + +.tagmap-sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + .tagmap-toast { + transition: none; + } + + .tagmap-progress { + animation: none; + } +} diff --git a/Client/TagMap/tests/bench-contour.js b/Client/TagMap/tests/bench-contour.js new file mode 100644 index 0000000..86103fd --- /dev/null +++ b/Client/TagMap/tests/bench-contour.js @@ -0,0 +1,97 @@ +/* + * What the contour costs, so the budget is measured rather than assumed. + * + * The design depends on one claim: contours are SCENE data, built once per + * navigation, not FRAME data. This prints the numbers that claim rests on. + * + * Run: node Client/TagMap/tests/bench-contour.js + */ +"use strict" + +const L = require("../tagmap-layout.js") + +/** Group reaches shaped like real tag data: a few big, a long thin tail. */ +function zipfReach(n) { + return Array.from({ length: n }, (_, i) => ({ + tag: "tag" + i, + reach: Math.max(1, Math.round(40 / (i + 1))), + })) +} + +/** A level with `perGroup` marks in each of `groups` territories. */ +function level(groups, perGroup) { + const children = zipfReach(groups) + const layout = L.layoutAnchors(children) + const levelR = L.levelRadiusFor(layout.packExtent, 0) + const rows = [] + for (let g = 0; g < groups; g++) { + for (let k = 0; k < perGroup; k++) rows.push(["g" + g + "-" + k, [g]]) + } + return { anchors: layout.anchors, levelR, marks: L.placeMarks(rows, layout.anchors, { levelRadius: levelR }) } +} + +function timed(label, runs, fn) { + fn() // warm up + const t0 = process.hrtime.bigint() + for (let i = 0; i < runs; i++) fn() + const ms = Number(process.hrtime.bigint() - t0) / 1e6 / runs + console.log(` ${label.padEnd(46)} ${ms.toFixed(2)} ms`) + return ms +} + +console.log("\n=== contour build, per level (this happens once per navigation) ===") +for (const [groups, perGroup] of [[25, 1], [25, 4], [106, 1], [106, 4], [200, 1], [200, 3]]) { + const scene = level(groups, perGroup) + let vertices = 0 + const ms = timed(`${groups} groups x ${perGroup} mark(s)`, 5, () => { + vertices = 0 + for (let g = 0; g < scene.anchors.length; g++) { + const members = scene.marks.filter((mark) => mark.group === g) + if (members.length === 0) continue + const r = L.kernelForPoints(members, scene.anchors[g].r) + const contour = L.contour(members.map((m) => ({ x: m.x, y: m.y, r }))) + for (const ring of contour.rings) vertices += ring.length + } + }) + console.log(` ${"".padEnd(46)} -> ${vertices} vertices, ${(ms / groups * 1000).toFixed(0)} us/group`) +} +console.log(" (compare against a 16.7 ms frame: this must stay well inside it,") +console.log(" and it is paid once per navigation rather than once per frame)") + +console.log("\n=== placement, per level (also once per navigation) ===") +for (const [groups, perGroup] of [[106, 4], [200, 20]]) { + const children = zipfReach(groups) + const layout = L.layoutAnchors(children) + const levelR = L.levelRadiusFor(layout.packExtent, 0) + const rows = [] + for (let g = 0; g < groups; g++) { + for (let k = 0; k < perGroup; k++) rows.push(["g" + g + "-" + k, [g]]) + } + timed(`placeMarks: ${rows.length} marks over ${groups} groups`, 20, + () => L.placeMarks(rows, layout.anchors, { levelRadius: levelR })) +} + +console.log("\n=== hit testing (per pointer event) ===") +{ + const scene = level(20, 4) + const members = scene.marks.filter((mark) => mark.group === 0) + const r = L.kernelForPoints(members, scene.anchors[0].r) + const contour = L.contour(members.map((m) => ({ x: m.x, y: m.y, r }))) + timed("pointInPolygon x 1000", 20, () => { + for (let i = 0; i < 1000; i++) { + L.pointInPolygon(contour.rings, (i % 40) - 20, (i % 31) - 15) + } + }) +} + +console.log("\n=== accuracy vs grid resolution (a lone mark, target radius PACK_K) ===") +for (const gridN of [17, 21, 25, 33]) { + const target = L.territoryRadius(1) + const contour = L.contour([{ x: 0, y: 0, r: L.kernelRadius(target, 1) }], { gridN }) + const radii = contour.rings[0].map((p) => Math.hypot(p.x, p.y)) + const min = Math.min(...radii), max = Math.max(...radii) + console.log(` gridN=${String(gridN).padEnd(3)} verts=${String(contour.rings[0].length).padEnd(4)}` + + ` radius ${min.toFixed(3)}..${max.toFixed(3)} of ${target.toFixed(3)}` + + ` error ${(((target - min) / target) * 100).toFixed(2)}%`) +} +console.log("") diff --git a/Client/TagMap/tests/field.test.js b/Client/TagMap/tests/field.test.js new file mode 100644 index 0000000..d7216fd --- /dev/null +++ b/Client/TagMap/tests/field.test.js @@ -0,0 +1,307 @@ +/* + * The density field and its contour. + * + * These are the properties that decide whether the picture can be trusted: + * that a group with no known contents draws exactly the circle it used to, + * that two groups merge only by sharing a point and never by being adjacent, + * and that the outline you see is the region the hit test will agree with. + * None of them can be checked by looking at a screenshot. + * + * Run: node --test Client/TagMap/tests/ + */ +"use strict" + +const test = require("node:test") +const assert = require("node:assert/strict") +const L = require("../tagmap-layout.js") + +const soloR = (territory) => L.kernelRadius(territory === undefined ? 1 : territory, 1) + +// --- the kernel ------------------------------------------------------------- + +test("kernel is 1 at the centre, zero beyond its radius, and monotone", () => { + const R = 0.8 + assert.equal(L.kernel(0, R * R), 1) + assert.equal(L.kernel(R * R, R * R), 0) + assert.equal(L.kernel(R * R * 4, R * R), 0, "compact support: nothing beyond R") + + let previous = Infinity + for (let d = 0; d <= R; d += R / 40) { + const v = L.kernel(d * d, R * R) + assert.ok(v <= previous + 1e-12, `not monotone at d=${d}`) + assert.ok(v >= 0 && v <= 1) + previous = v + } + // C¹ at the boundary: the slope dies out with the value. + const nearEdge = L.kernel((R * 0.999) ** 2, R * R) + assert.ok(nearEdge < 1e-7, `kernel should approach 0 smoothly, got ${nearEdge}`) +}) + +test("compact support means a distant point contributes exactly nothing", () => { + const points = [{ x: 0, y: 0, r: 0.5 }] + const withDistant = points.concat([{ x: 9, y: 9, r: 0.5 }]) + for (const [x, y] of [[0, 0], [0.2, 0.1], [0.49, 0]]) { + assert.equal(L.fieldAt(points, x, y), L.fieldAt(withDistant, x, y)) + } +}) + +// --- the degenerate case: no regression where there is no data -------------- + +test("a lone point draws exactly the circle of its territory", () => { + for (const territory of [0.15, 0.4, 1]) { + const contour = L.contour([{ x: 0, y: 0, r: soloR(territory) }]) + assert.equal(contour.rings.length, 1, "a lone point is one ring") + const radii = contour.rings[0].map((p) => Math.hypot(p.x, p.y)) + for (const radius of radii) { + assert.ok(Math.abs(radius - territory) <= territory * 0.01, + `isoline at ${radius}, expected ${territory} (±1%)`) + } + } +}) + +// --- merging happens by sharing, never by adjacency ------------------------- + +test("two separate groups do not merge, however far their territories overlap", () => { + // Load-bearing under the absolute scale. Anchors are no longer disjoint: + // sized by their own content counts they necessarily overlap, because a + // content carries several tags and the counts sum to more than the + // level's. Two groups must still draw two outlines -- merging is + // something only a SHARED CONTENT can cause, never proximity. + const r = soloR(L.territoryRadius(4)) + const a = { x: 0, y: 0, r } + const b = { x: L.territoryRadius(4) * 0.5, y: 0, r } // 50% overlap + const outlineA = L.contour([a]) + const outlineB = L.contour([b]) + assert.equal(outlineA.rings.length, 1, "group A is one ring") + assert.equal(outlineB.rings.length, 1, "group B is one ring") + + // Each field sees only its own points, so B's presence cannot change A. + assert.deepEqual(L.contour([a]).rings, outlineA.rings) + // And the outlines really do overlap -- that is the picture of nothing, + // yet; it becomes the picture of sharing once a content sits in both. + assert.ok(L.pointInPolygon(outlineA.rings, b.x, b.y), + "B's centre should fall inside A's outline at this overlap") +}) + +test("contents at the packing pitch read as one body; further apart they split", () => { + // This is what pins KERNEL_SPACING, and it pins it to something with a + // meaning: the merge distance IS the packing pitch, so "these read as one + // body" says "these sit at the density the layout packs them at" rather + // than naming a number someone liked the look of. At the old value of + // 1.35 even four pitches apart merged. + const nominal = L.kernelRadius(L.territoryRadius(2), 2) + const ringsAt = (pitches) => { + const d = L.CONTENT_PITCH * pitches + return L.contour([{ x: -d / 2, y: 0, r: nominal }, { x: d / 2, y: 0, r: nominal }]).rings.length + } + assert.equal(ringsAt(1.0), 1, "at exactly the pitch: one body") + assert.equal(ringsAt(1.2), 1, "a little further: still one body") + assert.equal(ringsAt(1.4), 2, "at 1.4 pitches: two lobes") + assert.equal(ringsAt(2.0), 2, "and they stay apart") +}) + +test("a group at the packing density gets the same kernel whatever its size", () => { + // territoryRadius(m) grows as sqrt(m) and the kernel shrinks as sqrt(m), + // so the two cancel: under the absolute scale the nominal kernel is one + // constant, PACK_K * KERNEL_SPACING / sqrt(1 - cbrt(FIELD_T)). A group of + // 100 therefore draws with the same softness as a group of 2 -- the shape + // differs because the POINTS differ, not because the blur was rescaled. + const expected = (L.PACK_K * L.KERNEL_SPACING) / Math.sqrt(1 - Math.cbrt(L.FIELD_T)) + for (const m of [2, 5, 20, 100, 4000]) { + assert.ok(Math.abs(L.kernelRadius(L.territoryRadius(m), m) - expected) < 1e-12, + `m=${m}: ${L.kernelRadius(L.territoryRadius(m), m)}, expected ${expected}`) + } + // The lone-point case is deliberately outside that rule: it must + // reproduce the territory circle exactly, whatever the spacing constant. + assert.ok(L.kernelRadius(L.territoryRadius(1), 1) > expected) +}) + +test("a shared point pulls the outline toward it", () => { + // One group of two points; the outline must reach the far one. + const r = soloR(0.3) + const shared = { x: 0.55, y: 0 } + const contour = L.contour([{ x: 0, y: 0, r }, { x: shared.x, y: shared.y, r }]) + assert.ok(L.pointInPolygon(contour.rings, shared.x, shared.y), + "the shared point must lie inside its group's outline") + const reach = Math.max(...contour.rings[0].map((p) => p.x)) + assert.ok(reach > 0.6, `outline should bulge past the shared point, reached ${reach}`) +}) + +// --- the shape you see is the shape you can tap ----------------------------- + +test("point-in-polygon agrees with the field threshold", () => { + const r = soloR(0.4) + const points = [{ x: -0.2, y: -0.1, r }, { x: 0.25, y: 0.15, r }, { x: 0, y: 0.4, r }] + const contour = L.contour(points) + // The sampling cell, for the failure message. This used to read a + // CONTOUR_EXTENT that the module does not export, so it was NaN and + // nobody noticed -- it only ever appeared inside an assertion message. + const cell = (2 * L.fieldBounds(points).half) / (L.GRID_N - 1) + + let disagreements = 0 + let probes = 0 + for (let i = 0; i < 60; i++) { + for (let j = 0; j < 60; j++) { + const x = -1.2 + (2.4 * i) / 59 + const y = -1.2 + (2.4 * j) / 59 + const value = L.fieldAt(points, x, y) + // Skip a band one cell wide around the isoline: that is the + // discretisation error, not a disagreement. + const distanceToEdge = Math.abs(value - L.FIELD_T) + if (distanceToEdge < 0.08) continue + probes++ + if (L.pointInPolygon(contour.rings, x, y) !== (value >= L.FIELD_T)) disagreements++ + } + } + assert.ok(probes > 1000, "the probe grid should actually cover the shape") + assert.equal(disagreements, 0, + `${disagreements}/${probes} probes where the drawn shape and the field disagree (cell ${cell})`) +}) + +test("the label anchor is inside the group's own outline", () => { + const r = soloR(0.35) + const points = [{ x: -0.3, y: 0, r }, { x: 0.3, y: 0.1, r }, { x: 0, y: -0.35, r }] + const contour = L.contour(points) + assert.ok(L.pointInPolygon(contour.rings, contour.anchor.x, contour.anchor.y)) + assert.ok(contour.anchor.value >= L.FIELD_T) +}) + +// --- well-formedness -------------------------------------------------------- + +test("rings are closed, finite, and enclose a real area", () => { + const r = soloR(0.3) + for (const n of [1, 2, 3, 7, 20]) { + const points = L.layoutInside(n).map((p) => ({ x: p.x * 0.6, y: p.y * 0.6, r })) + const contour = L.contour(points) + assert.ok(contour.rings.length >= 1, `n=${n}: no outline at all`) + for (const ring of contour.rings) { + assert.deepEqual(ring[0], ring[ring.length - 1], `n=${n}: ring not closed`) + for (const p of ring) { + assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y), `n=${n}: non-finite vertex`) + } + assert.ok(Math.abs(L.polygonArea(ring)) > 1e-6, `n=${n}: degenerate ring`) + } + assert.ok(contour.area > 0) + } +}) + +test("an empty group has no outline rather than an invented one", () => { + const contour = L.contour([]) + assert.deepEqual(contour.rings, []) + assert.equal(contour.area, 0) +}) + +// --- determinism ------------------------------------------------------------ + +test("the contour is deterministic and free of hidden state", () => { + const r = soloR(0.35) + const points = [{ x: 0.1, y: -0.2, r }, { x: -0.25, y: 0.3, r }, { x: 0.4, y: 0.05, r }] + assert.deepEqual(L.contour(points), L.contour(points)) + // No dependence on object identity. + assert.deepEqual(L.contour(JSON.parse(JSON.stringify(points))), L.contour(points)) +}) + +test("the field uses no clock and no randomness", () => { + const realRandom = Math.random + const realNow = Date.now + Math.random = () => { throw new Error("Math.random must not be reachable") } + Date.now = () => { throw new Error("Date.now must not be reachable") } + try { + const r = soloR(0.4) + L.contour([{ x: 0, y: 0, r }, { x: 0.3, y: 0.2, r }]) + L.smoothstep(0, 1, 0.4) + } finally { + Math.random = realRandom + Date.now = realNow + } +}) + +test("the outline does not depend on how many points share a position", () => { + // Two coincident points must not produce a different shape from one -- + // otherwise duplicate data would silently change the picture. + const r = soloR(0.3) + const one = L.contour([{ x: 0, y: 0, r }]) + const two = L.contour([{ x: 0, y: 0, r }, { x: 0, y: 0, r }]) + const radiusOf = (c) => Math.max(...c.rings[0].map((p) => Math.hypot(p.x, p.y))) + // The bounded union saturates, so the second point adds reach but the + // shape stays a circle. Assert it is still a circle, and bounded. + const radii = two.rings[0].map((p) => Math.hypot(p.x, p.y)) + const round = (Math.max(...radii) - Math.min(...radii)) / Math.max(...radii) + assert.ok(round < 0.02, `still a circle, roundness error ${round}`) + assert.ok(radiusOf(two) >= radiusOf(one), "more mass cannot shrink the shape") +}) + +// --- smoothstep ------------------------------------------------------------- + +test("smoothstep is exact at its endpoints and monotone between", () => { + assert.equal(L.smoothstep(0, 1, -5), 0) + assert.equal(L.smoothstep(0, 1, 0), 0) + assert.equal(L.smoothstep(0, 1, 1), 1) + assert.equal(L.smoothstep(0, 1, 5), 1) + let previous = -1 + for (let x = 0; x <= 1; x += 0.02) { + const v = L.smoothstep(0, 1, x) + assert.ok(v >= previous - 1e-12, `not monotone at ${x}`) + previous = v + } + assert.equal(L.smoothstep(2, 2, 1), 0, "a zero-width ramp is a step") + assert.equal(L.smoothstep(2, 2, 3), 1) +}) + +// --- the fusion melt --------------------------------------------------------- + +test("two groups held together melt into exactly one body", () => { + // Fusing A and B used to be a switch: release, and a new picture + // appeared. Drawn as one soft body over both groups' marks while the + // reader holds them, the gesture shows what it is about to produce -- + // and it is the same field over the same marks the fused level will + // draw, so the preview cannot disagree with the result. + // + // The kernel that closes the gap comes from the CLOSEST CROSS-GROUP pair, + // not from the distance between territories: what has to merge is the + // marks. 0.95 of that distance clears the Wyvill kernel's pairwise merge + // constant (0.8625) with margin at every group size, which is what this + // checks -- a single constant tuned to one size would fail at others. + for (const [reachA, reachB] of [[1, 1], [2, 1], [9, 4], [33, 17], [65, 3]]) { + const layout = L.layoutAnchors([{ tag: "a", reach: reachA }, { tag: "b", reach: reachB }]) + const rows = [] + for (let i = 0; i < reachA; i++) rows.push(["a" + i, [0]]) + for (let i = 0; i < reachB; i++) rows.push(["b" + i, [1]]) + const marks = L.placeMarks(rows, layout.anchors, + { levelRadius: L.levelRadiusFor(layout.packExtent, 0) }) + + const mine = marks.filter((m) => m.group === 0) + const theirs = marks.filter((m) => m.group === 1) + let crossGap = Infinity + for (const p of mine) { + for (const q of theirs) crossGap = Math.min(crossGap, Math.hypot(p.x - q.x, p.y - q.y)) + } + const natural = L.kernelForPoints(marks, + Math.max(layout.anchors[0].r, layout.anchors[1].r)) + const bridging = Math.max(natural, crossGap * 0.95) + + // Bodies are counted by the SIGN of the enclosed area, not by ring + // count: a group of 33 marks at the packing density is one body with + // four holes, and holes are rings too. Counting rings would have made + // this test read "5 bodies" and sent the next reader after a bug that + // is not there. + const bodies = (rings) => rings.filter((ring) => L.polygonArea(ring) > 0).length + const holes = (rings) => rings.filter((ring) => L.polygonArea(ring) < 0).length + + const fused = L.contour(marks.map((m) => ({ x: m.x, y: m.y, r: bridging }))) + assert.equal(bodies(fused.rings), 1, + `reach ${reachA}/${reachB}: the melt left ${bodies(fused.rings)} bodies`) + assert.equal(holes(fused.rings), 0, + `reach ${reachA}/${reachB}: the melt should close the gaps, not leave holes`) + + // Unfused, the same marks are two bodies -- so it is the melt that + // changed the picture, not the placement. + const separate = [0, 1].map((g) => { + const members = marks.filter((m) => m.group === g) + const r = L.kernelForPoints(members, layout.anchors[g].r) + return bodies(L.contour(members.map((m) => ({ x: m.x, y: m.y, r }))).rings) + }) + assert.equal(separate[0] + separate[1] >= 2, true, + `reach ${reachA}/${reachB}: not separate to begin with`) + } +}) diff --git a/Client/TagMap/tests/layout.test.js b/Client/TagMap/tests/layout.test.js new file mode 100644 index 0000000..cf44559 --- /dev/null +++ b/Client/TagMap/tests/layout.test.js @@ -0,0 +1,104 @@ +/* + * Invariants of the layout that are not about scale. + * + * The sizing, packing, composition and determinism tests moved to + * scale.test.js when the layout became absolute. What stays here is + * independent of that: OR fusion, the free band's stability under paging, + * and the in-a-group sunflower. The old grid over layoutChildren, the + * transform chain and the re-anchor identity went with the machinery they + * tested -- the renormalised space they made continuous no longer exists. + * + * Run: node --test Client/TagMap/tests/ + */ +"use strict" + +const test = require("node:test") +const assert = require("node:assert/strict") +const L = require("../tagmap-layout.js") + +// --- fixtures --------------------------------------------------------------- + +/** A level's territories, shaped like real tag data: a few big, a long tail. */ +function territories(n) { + const layout = L.layoutAnchors(Array.from({ length: n }, (_, i) => ({ + tag: "tag" + i, + reach: Math.max(1, Math.round(40 / (i + 1))), + }))) + return { slots: layout.anchors, byTag: layout.byTag, packExtent: layout.packExtent } +} + +// --- OR fusion -------------------------------------------------------------- + +test("a fused segment is one circle containing every member", () => { + const layout = territories(20) + for (const size of [2, 3, 4, 5]) { + const segment = layout.slots.slice(0, size).map((s) => s.tag) + const fused = L.slotForSegment(layout, segment) + assert.ok(fused, "fusion returned null") + assert.deepEqual(fused.members, segment) + for (const tag of segment) { + const member = layout.byTag[tag] + const reach = Math.hypot(member.x - fused.x, member.y - fused.y) + member.r + assert.ok(reach <= fused.r + 1e-9, + `${tag} sticks out of the fused circle by ${reach - fused.r}`) + } + } +}) + +test("an unknown tag yields null rather than an invented slot", () => { + const layout = territories(5) + assert.equal(L.slotForSegment(layout, ["nope"]), null) + assert.equal(L.slotForSegment(layout, ["tag0", "nope"]), null) + assert.equal(L.slotForSegment(layout, []), null) + assert.equal(L.slotForSegment(null, ["tag0"]), null) +}) + +// --- the band --------------------------------------------------------------- + +test("appending a page cannot move an existing dot", () => { + const items = Array.from({ length: 200 }, (_, i) => ({ url: "/content/" + i })) + const first = L.layoutBand(items.slice(0, 10)) + const second = L.layoutBand(items.slice(0, 30)) + const third = L.layoutBand(items) + assert.deepEqual(second.slice(0, 10), first) + assert.deepEqual(third.slice(0, 30), second) +}) + +test("band dots sit outside the children and inside the boundary", () => { + const dots = L.layoutBand(Array.from({ length: 50 }, (_, i) => ({ url: "/c" + i }))) + for (const dot of dots) { + const radius = Math.hypot(dot.x, dot.y) + assert.ok(radius > L.INNER_FILL, `dot at ${radius} is inside the packed children`) + assert.ok(radius < 1, `dot at ${radius} is outside the boundary`) + } +}) + +// --- contents shown inside a child group ------------------------------------ + +test("inside-a-group contents stay inside and never coincide", () => { + for (const n of [1, 2, 3, 5, 10, 25]) { + const dots = L.layoutInside(n) + assert.equal(dots.length, n) + for (const dot of dots) { + const radius = Math.hypot(dot.x, dot.y) + assert.ok(radius <= L.INSIDE_FILL + 1e-9, + `n=${n}: a dot at ${radius} escapes INSIDE_FILL ${L.INSIDE_FILL}`) + } + for (let i = 0; i < dots.length; i++) { + for (let j = i + 1; j < dots.length; j++) { + const gap = Math.hypot(dots[i].x - dots[j].x, dots[i].y - dots[j].y) + assert.ok(gap > 1e-6, `n=${n}: dots ${i} and ${j} coincide`) + } + } + } + assert.deepEqual(L.layoutInside(1), [{ index: 0, x: 0, y: 0 }]) + assert.deepEqual(L.layoutInside(0), []) +}) + +test("inside-a-group layout is deterministic (but not prefix-stable)", () => { + assert.deepEqual(L.layoutInside(7), L.layoutInside(7)) + // Documented non-property: the ring radii depend on the count, which is + // why this population is fetched in one go and never paged. + assert.notDeepEqual(L.layoutInside(3), L.layoutInside(8).slice(0, 3)) +}) + diff --git a/Client/TagMap/tests/placement.test.js b/Client/TagMap/tests/placement.test.js new file mode 100644 index 0000000..3bfde7e --- /dev/null +++ b/Client/TagMap/tests/placement.test.js @@ -0,0 +1,326 @@ +/* + * Placing marks: one per (content, group) pair. + * + * This file used to assert the opposite -- that a content shared by two + * groups got exactly ONE mark, as a property of a partition. That property + * was achieved and then given up on purpose. A single mark has to sit between + * its groups, which stretches both outlines toward it, and on the reference + * corpus (192 contents, 106 groups) every one of the 5565 outline pairs then + * crossed: the level was unreadable. Duplicating the mark brings that to + * zero, and identity moves out of the geometry into the `key`, where the + * instances of one content can be tied together on demand. + * + * What replaces "exactly once" is stronger than it sounds: every mark is + * inside its own group, and no two marks ever overlap -- provably, with no + * relaxation pass at all. + * + * Run: node --test Client/TagMap/tests/ + */ +"use strict" + +const test = require("node:test") +const assert = require("node:assert/strict") +const L = require("../tagmap-layout.js") + +/** + * A membership manifest shaped like the server's: `[[key, [group, ...]], ...]` + * mostly solo, some shared, some direct. + */ +function manifest(contentCount, groupCount) { + const rows = [] + for (let i = 0; i < contentCount; i++) { + const key = "c" + i + const pick = (...offsets) => Array.from(new Set(offsets.map((o) => (i + o) % groupCount))) + if (i % 7 === 0) rows.push([key, []]) // direct + else if (i % 5 === 0) rows.push([key, pick(0, 3)]) + else if (i % 11 === 0) rows.push([key, pick(1, 2, 5)]) + else rows.push([key, pick(0)]) + } + return rows +} + +/** + * A level whose group reaches are DERIVED from its manifest, so the fixture + * cannot claim a group holds fewer contents than the manifest puts in it. + * The server guarantees that relation (reach is the child view's content + * count), and a fixture that breaks it measures the containment guard rather + * than the layout. + */ +function level(groups, contents) { + const rows = manifest(contents === undefined ? groups * 4 : contents, groups) + const counts = new Array(groups).fill(0) + let direct = 0 + for (const row of rows) { + const known = row[1].filter((g) => g >= 0 && g < groups) + if (known.length === 0) direct++ + for (const g of known) counts[g]++ + } + const children = counts.map((count, i) => ({ tag: "g" + i, reach: Math.max(1, count) })) + const layout = L.layoutAnchors(children) + return Object.assign({ + rows, children, direct, + levelR: L.levelRadiusFor(layout.packExtent, direct), + }, layout) +} + +/** Just the territories, for tests that do not care about the level. */ +function territories(n) { + return level(n).anchors +} + +// --- one mark per membership ------------------------------------------------- + +test("a content gets one mark in each of its groups, and no others", () => { + for (const [contents, groups] of [[9, 3], [29, 21], [192, 106], [400, 60]]) { + const lvl = level(groups, contents) + const rows = lvl.rows + const marks = L.placeMarks(rows, lvl.anchors, { levelRadius: lvl.levelR }) + + let expected = 0 + rows.forEach((row) => { + const known = row[1].filter((g) => lvl.anchors[g]) + expected += known.length === 0 ? 1 : known.length + }) + assert.equal(marks.length, expected, + `${contents} contents over ${groups} groups: expected ${expected} marks`) + + // Every mark is in a group the content actually carries, and every + // membership produced exactly one mark. + const seen = new Set() + for (const mark of marks) { + assert.ok(Number.isFinite(mark.x) && Number.isFinite(mark.y)) + const pair = mark.content + ":" + mark.group + assert.ok(!seen.has(pair), `duplicate mark for ${pair}`) + seen.add(pair) + if (mark.group >= 0) { + assert.ok(rows[mark.content][1].indexOf(mark.group) >= 0, + `mark placed in a group its content does not carry`) + } else { + assert.ok(rows[mark.content][1].every((g) => !lvl.anchors[g]), + `a content with a known group was sent to the band`) + } + } + // Nothing is dropped: every content appears at least once. + assert.equal(new Set(marks.map((m) => m.content)).size, contents) + } +}) + +test("every instance of one content carries the same key", () => { + // The identity duplication gave up in the geometry has to survive + // somewhere, or the instances cannot be tied together, tweened across a + // level change, or counted. + const lvl = level(6) + const rows = [["shared", [0, 1, 2]], ["solo", [3]], ["direct", []]] + const marks = L.placeMarks(rows, lvl.anchors, { levelRadius: lvl.levelR }) + + const shared = marks.filter((m) => m.key === "shared") + assert.equal(shared.length, 3) + assert.deepEqual(shared.map((m) => m.group).sort((a, b) => a - b), [0, 1, 2]) + assert.equal(marks.filter((m) => m.key === "solo").length, 1) + assert.equal(marks.filter((m) => m.key === "direct").length, 1) + assert.equal(marks.find((m) => m.key === "direct").inBand, true) +}) + +test("out-of-cap and direct contents both ring the band", () => { + const lvl = level(4, 3) + // -1 means "its group is past the response's cap", which is not the same + // as "it has no group" -- but both belong to the level rather than to a + // territory, so both ring the band. + const rows = [["a", []], ["b", [-1]], ["c", [0]]] + const marks = L.placeMarks(rows, lvl.anchors, { levelRadius: lvl.levelR }) + const band = marks.filter((m) => m.inBand) + assert.equal(band.length, 2) + assert.deepEqual(band.map((m) => m.key), ["a", "b"]) + for (const mark of band) { + const reach = Math.hypot(mark.x, mark.y) / lvl.levelR + assert.ok(reach > L.BAND_RINGS[0] - 1e-9 && reach < L.BAND_RINGS[2] + 1e-9, + `band mark at ${reach} of the level radius`) + } + // The two band marks must not be stacked on each other. + assert.ok(Math.hypot(band[0].x - band[1].x, band[0].y - band[1].y) > L.CONTENT_PITCH) +}) + +// --- containment and separation ---------------------------------------------- + +test("every mark is inside its own group", () => { + for (const [contents, groups] of [[29, 21], [192, 106], [400, 60]]) { + const lvl = level(groups, contents) + const marks = L.placeMarks(lvl.rows, lvl.anchors, { levelRadius: lvl.levelR }) + for (const mark of marks) { + if (mark.inBand) continue + const anchor = lvl.anchors[mark.group] + const inside = Math.hypot(mark.x - anchor.x, mark.y - anchor.y) + assert.ok(inside <= anchor.r + 1e-9, + `${mark.key} sits ${inside} from its group's centre, radius ${anchor.r}`) + } + } +}) + +test("two marks never overlap, with no relaxation pass at all", () => { + // This is what replaces the old relax() and its honest disclaimer that it + // could not promise a bound. The sunflower inside a group is at least + // 1.02 CONTENT_PITCH apart at every size, and the groups are disjoint, so + // the bound is a property of the construction rather than of a budget. + for (const [contents, groups] of [[29, 21], [192, 106], [400, 60]]) { + const lvl = level(groups, contents) + const marks = L.placeMarks(lvl.rows, lvl.anchors, { levelRadius: lvl.levelR }) + let closest = Infinity + for (let i = 0; i < marks.length; i++) { + for (let j = i + 1; j < marks.length; j++) { + const d = Math.hypot(marks[i].x - marks[j].x, marks[i].y - marks[j].y) + if (d < closest) closest = d + } + } + assert.ok(closest >= 2 * L.CONTENT_R, + `${contents}/${groups}: two marks are ${closest} apart, discs of radius ${L.CONTENT_R} overlap`) + } +}) + +test("the sunflower alone keeps a group's marks a pitch apart", () => { + // Measured across sizes rather than argued: the worst case is 1.02 + // pitches, and it is flat above four marks. + for (const count of [2, 3, 4, 5, 7, 10, 17, 33, 65, 400, 1000]) { + const spread = L.spreadForCell(count) + const points = Array.from({ length: count }, (_, i) => { + const local = L.insideSlot(i, count) + return { x: local.x * spread, y: local.y * spread } + }) + let closest = Infinity + for (let i = 0; i < count; i++) { + for (let j = i + 1; j < count; j++) { + const d = Math.hypot(points[i].x - points[j].x, points[i].y - points[j].y) + if (d < closest) closest = d + } + } + assert.ok(closest >= L.CONTENT_PITCH, + `${count} marks: closest pair ${closest}, pitch ${L.CONTENT_PITCH}`) + } +}) + +test("a group's marks occupy the room its OWN count needs", () => { + // Not the room its reach needs: when fewer contents are known than the + // group's reach, the shape states what is known while the label still + // states the count. + const lvl = level(8) + const anchor = lvl.anchors[0] + for (const known of [1, 4, 25]) { + const rows = Array.from({ length: known }, (_, i) => ["k" + i, [0]]) + const marks = L.placeMarks(rows, lvl.anchors, { levelRadius: lvl.levelR }) + const furthest = Math.max(...marks.map((m) => Math.hypot(m.x - anchor.x, m.y - anchor.y))) + assert.ok(furthest <= L.spreadForCell(known) + 1e-9, + `${known} marks reached ${furthest}, budget ${L.spreadForCell(known)}`) + } +}) + +// --- the anonymous-to-real invariant ------------------------------------------ + +test("a content lands exactly where its anonymous stand-in was", () => { + const lvl = level(5) + const groupIndex = 2 + const count = 9 + const marks = L.anonymousMarks(count, lvl.anchors[groupIndex]) + assert.equal(marks.length, count) + + const rows = Array.from({ length: count }, (_, i) => ["c" + i, [groupIndex]]) + const real = L.placeMarks(rows, lvl.anchors, { levelRadius: lvl.levelR }) + + for (let i = 0; i < count; i++) { + assert.ok(Math.abs(real[i].x - marks[i].x) < 1e-12 + && Math.abs(real[i].y - marks[i].y) < 1e-12, + `content ${i} moved: mark (${marks[i].x}, ${marks[i].y}) ` + + `vs real (${real[i].x}, ${real[i].y})`) + } +}) + +test("capping the marks preserves the group's mass", () => { + const anchor = territories(3)[0] + const capped = L.anonymousMarks(500, anchor, 40) + assert.equal(capped.length, 40) + const mass = capped.reduce((sum, m) => sum + m.w, 0) + assert.ok(Math.abs(mass - 500) < 1e-9, `mass ${mass}, expected 500`) + for (const mark of capped) assert.equal(mark.anonymous, true) +}) + +test("insideSlot is prefix-stable in the index for a fixed capacity", () => { + const capacity = 12 + const few = L.layoutInside(4, capacity) + const many = L.layoutInside(capacity, capacity) + assert.deepEqual(few, many.slice(0, 4)) +}) + +// --- determinism -------------------------------------------------------------- + +test("placement is deterministic and free of hidden state", () => { + const lvl = level(12, 60) + const rows = lvl.rows + const once = L.placeMarks(rows, lvl.anchors, { levelRadius: lvl.levelR }) + assert.deepEqual(L.placeMarks(rows, lvl.anchors, { levelRadius: lvl.levelR }), once) + // No dependence on object identity. + assert.deepEqual( + L.placeMarks(JSON.parse(JSON.stringify(rows)), lvl.anchors, { levelRadius: lvl.levelR }), + once) +}) + +test("placement uses no clock and no randomness", () => { + const realRandom = Math.random + const realNow = Date.now + Math.random = () => { throw new Error("Math.random must not be reachable") } + Date.now = () => { throw new Error("Date.now must not be reachable") } + try { + const lvl = level(8, 40) + L.placeMarks(lvl.rows, lvl.anchors, { levelRadius: lvl.levelR }) + L.anonymousMarks(20, lvl.anchors[0], 10) + L.spreadForCell(7) + L.levelRadiusFor(30, 4) + } finally { + Math.random = realRandom + Date.now = realNow + } +}) + +// --- the outlines stay apart -------------------------------------------------- + +test("a group's contour contains all of its own marks", () => { + const lvl = level(6, 40) + const marks = L.placeMarks(lvl.rows, lvl.anchors, { levelRadius: lvl.levelR }) + for (let g = 0; g < lvl.anchors.length; g++) { + const members = marks.filter((m) => m.group === g) + if (members.length === 0) continue + const r = L.kernelForPoints(members, lvl.anchors[g].r) + const outline = L.contour(members.map((m) => ({ x: m.x, y: m.y, r }))) + for (const mark of members) { + assert.ok(L.pointInPolygon(outline.rings, mark.x, mark.y), + `group ${g}: a mark lies outside its own contour`) + } + } +}) + +test("no two contours overlap, which is what duplication bought", () => { + // The claim the reversal rests on. With one mark per content the shared + // marks sat between their groups and every outline crossed every other; + // with a mark per membership each outline stays inside its own territory. + // If this ever fails, the level has gone back to being a hairball. + const groups = 40 + const lvl = level(groups, 120) + const marks = L.placeMarks(lvl.rows, lvl.anchors, { levelRadius: lvl.levelR }) + + const outlines = [] + for (let g = 0; g < lvl.anchors.length; g++) { + const members = marks.filter((m) => m.group === g) + if (members.length === 0) continue + const r = L.kernelForPoints(members, lvl.anchors[g].r) + outlines.push({ g, rings: L.contour(members.map((m) => ({ x: m.x, y: m.y, r }))).rings }) + } + + let crossing = 0 + for (let i = 0; i < outlines.length; i++) { + for (let j = i + 1; j < outlines.length; j++) { + const a = outlines[i], b = outlines[j] + const hits = (from, into) => from.rings.some( + (ring) => ring.some((v) => L.pointInPolygon(into.rings, v.x, v.y))) + if (hits(a, b) || hits(b, a)) crossing++ + } + } + assert.equal(crossing, 0, + `${crossing} of ${(outlines.length * (outlines.length - 1)) / 2} outline pairs overlap`) +}) diff --git a/Client/TagMap/tests/scale.test.js b/Client/TagMap/tests/scale.test.js new file mode 100644 index 0000000..203cabb --- /dev/null +++ b/Client/TagMap/tests/scale.test.js @@ -0,0 +1,843 @@ +/* + * The absolute scale: one content radius is the world unit. + * + * Nothing here is normalised per level, which is the whole point -- the old + * model made the current level radius 1 and renormalised on every step, so + * you could zoom for ever and no two levels were comparable. These tests pin + * the replacement: a region's size states a content count, and it states the + * same count wherever it appears on the map. + * + * The numbers in the comments were measured on the reference corpus (192 + * contents, 106 groups) -- see the plan's W1 section. + * + * Run: node --test Client/TagMap/tests/ + */ +"use strict" + +const test = require("node:test") +const assert = require("node:assert/strict") +const L = require("../tagmap-layout.js") + +// --- fixtures --------------------------------------------------------------- + +/** Group reaches shaped like real tag data: a few big, a long thin tail. */ +function zipfReach(n) { + return Array.from({ length: n }, (_, i) => ({ + tag: "tag" + i, + reach: Math.max(1, Math.round(40 / (i + 1))), + })) +} + +const DISTRIBUTIONS = { + equal: (n) => Array.from({ length: n }, (_, i) => ({ tag: "t" + i, reach: 7 })), + zipf: zipfReach, + extreme: (n) => Array.from({ length: n }, (_, i) => ({ tag: "t" + i, reach: i === 0 ? 1000 : 1 })), + ascending: (n) => Array.from({ length: n }, (_, i) => ({ tag: "t" + i, reach: i + 1 })), +} +const SIZES = [1, 2, 3, 5, 17, 60, 200] + +/** A level: its territories, and the radius they oblige it to have. */ +function levelOf(children, direct) { + const layout = L.layoutAnchors(children) + return Object.assign({ + levelR: L.levelRadiusFor(layout.packExtent, direct || 0), + direct: direct || 0, + }, layout) +} + +/** + * A level with actual marks in it: reaches derived from the manifest, so the + * fixture cannot claim a group holds fewer contents than it is given. + */ +function markedLevel(groups, contents) { + const rows = [] + for (let i = 0; i < contents; i++) { + const pick = (...offsets) => Array.from(new Set(offsets.map((o) => (i + o) % groups))) + if (i % 7 === 0) rows.push(["c" + i, []]) + else if (i % 5 === 0) rows.push(["c" + i, pick(0, 3)]) + else rows.push(["c" + i, pick(0)]) + } + const counts = new Array(groups).fill(0) + let direct = 0 + for (const row of rows) { + const known = row[1].filter((g) => g >= 0 && g < groups) + if (known.length === 0) direct++ + for (const g of known) counts[g]++ + } + const level = levelOf(counts.map((count, i) => ({ tag: "g" + i, reach: Math.max(1, count) })), direct) + return Object.assign({ + rows, + marks: L.placeMarks(rows, level.anchors, { levelRadius: level.levelR }), + }, level) +} + +// --- the sizing function ---------------------------------------------------- + +test("the packing constant IS the pitch, so a region of n contents is sqrt(n) pitches", () => { + // PACK_K = (CONTENT_PITCH/2)/sqrt(fill) with fill = 0.25. Stating it as + // the pitch removes a constant rather than hiding one: there is no + // separate fill factor to tune, and the derivation is checkable here. + assert.equal(L.PACK_K, L.CONTENT_PITCH) + assert.ok(Math.abs(L.PACK_K - (L.CONTENT_PITCH / 2) / Math.sqrt(0.25)) < 1e-12) + assert.equal(L.territoryRadius(1), L.CONTENT_PITCH) + assert.ok(Math.abs(L.territoryRadius(9) - 3 * L.CONTENT_PITCH) < 1e-12) +}) + +test("radius is exactly proportional to sqrt(count), so area states the count", () => { + // Not merely monotone. The old layout only promised "a heavier child is + // not smaller", which permitted any curve; here the AREA is the count, + // and it is the same count at every level in the map. + for (const n of [1, 2, 7, 50, 192, 4000]) { + assert.ok(Math.abs(L.territoryRadius(n) - L.PACK_K * Math.sqrt(n)) < 1e-12) + assert.ok(Math.abs(L.territoryRadius(4 * n) - 2 * L.territoryRadius(n)) < 1e-12, + `four times the contents must be exactly twice the radius (n=${n})`) + } + assert.equal(L.territoryRadius(0), 0) + assert.equal(L.territoryRadius(-5), 0, "a negative count is not a radius") + // The cell spread is the same function, which is what keeps an anonymous + // mark and the content it becomes on the same point. + assert.equal(L.spreadForCell(9), L.territoryRadius(9)) +}) + +test("the smallest possible group is never smaller than one content", () => { + // This is what replaces MIN_CHILD_RATIO, and it replaces it with a + // scale-free statement instead of a ratio. + // + // The floor existed because a normalised layout made the smallest of 200 + // children sub-pixel: its size was a fraction of the largest, so a long + // tail vanished. Absolutely, the smallest group is CONTENT_PITCH -- one + // content plus its gutter -- no matter what else is on the map. So it can + // only be sub-pixel if a CONTENT is sub-pixel, and at that zoom the level + // of detail is drawing density rather than marks anyway. + // + // Note the ratio to the largest is now 0.03, where the deleted + // MIN_CHILD_RATIO floor would have held it at 0.12. That is not a + // regression: the ratio stopped being the thing that decides visibility. + const level = levelOf(DISTRIBUTIONS.extreme(200)) + const radii = level.anchors.map((a) => a.r) + assert.equal(Math.min(...radii), L.territoryRadius(1)) + assert.ok(Math.min(...radii) >= 2 * L.CONTENT_R, + "a group must be at least as wide as the content it holds") + assert.ok(Math.min(...radii) / Math.max(...radii) < 0.05, + "a ratio near the old 0.12 floor would mean radii are being rescaled again") +}) + +test("a level is sized to hold its pack, not the pack squeezed to fit it", () => { + // The direction of the dependency is the whole reason the territories can + // stay disjoint: nothing is scaled to fit, so the packSiblings guarantee + // survives to the screen. + for (const n of SIZES) { + const level = levelOf(zipfReach(n)) + assert.ok(level.packExtent > 0) + assert.ok(Math.abs(level.levelR - level.packExtent / L.INNER_FILL) < 1e-9, + `n=${n}: level ${level.levelR} is not the pack ${level.packExtent} over INNER_FILL`) + // The band outside the pack is what INNER_FILL leaves room for. + assert.ok(level.levelR > level.packExtent) + } + // A level with many direct contents and few groups is sized by the band + // instead, so those contents have somewhere to be. + const banded = levelOf(zipfReach(1), 400) + assert.ok(Math.abs(banded.levelR - L.territoryRadius(400) / L.INNER_FILL) < 1e-9) + // And an empty level is not a division by zero. + assert.equal(L.levelRadiusFor(0, 0), 0) +}) + +test("the zoom range is finite and grows only as sqrt(N)", () => { + // The whole reason for the absolute scale: "you cannot keep zooming" has + // to be a number, not an intention. + const K_MAX = 28, FIT_FILL = 0.94, viewport = 800 + const rangeFor = (marks) => { + const levelR = L.levelRadiusFor(L.territoryRadius(marks), 0) + return K_MAX / ((viewport * FIT_FILL) / (2 * levelR)) + } + assert.ok(rangeFor(411) < 10, `the root's 411 marks should be under 10x, got ${rangeFor(411).toFixed(2)}`) + assert.ok(rangeFor(100000) < 150, `even 100k marks stay bounded, got ${rangeFor(100000).toFixed(2)}`) + // Quadrupling the map exactly doubles the range. + assert.ok(Math.abs(rangeFor(4 * 411) - 2 * rangeFor(411)) < 1e-9) +}) + +// --- territories ------------------------------------------------------------ + +for (const [name, make] of Object.entries(DISTRIBUTIONS)) { + for (const n of SIZES) { + test(`territories state their counts and never overlap: ${name}, n=${n}`, () => { + const children = make(n) + const level = levelOf(children) + assert.equal(level.anchors.length, n) + + for (const anchor of level.anchors) { + assert.ok(Number.isFinite(anchor.x) && Number.isFinite(anchor.y) + && Number.isFinite(anchor.r), `non-finite anchor: ${JSON.stringify(anchor)}`) + // (a) the radius is the count, exactly -- never rescaled to + // fit, because a rescaled radius would state a different + // count than the label beside it. + assert.equal(anchor.r, L.territoryRadius(anchor.reach)) + // (b) the whole territory is inside the level, rim included. + assert.ok(Math.hypot(anchor.x, anchor.y) + anchor.r + <= L.INNER_FILL * level.levelR + 1e-9, + `${anchor.tag} reaches past the level's inner field`) + } + + // (c) tangent, never overlapping -- all the way to the screen. + for (let i = 0; i < level.anchors.length; i++) { + for (let j = i + 1; j < level.anchors.length; j++) { + const a = level.anchors[i], b = level.anchors[j] + const distance = Math.hypot(a.x - b.x, a.y - b.y) + const touching = a.r + b.r + // Relative epsilon: front-chain packing lands tangent to + // within a few ULPs, so an absolute one is too strict. + assert.ok(distance >= touching - 1e-9 * touching, + `${a.tag} overlaps ${b.tag} by ${touching - distance}`) + } + } + }) + } +} + +test("territories must NOT overlap, whatever the sharing", () => { + // This test was once the opposite. Under one-mark-per-content the + // territories had to overlap -- the groups' counts sum to about 2.5x the + // level's, so absolutely-sized disjoint circles could not fit -- and the + // overlapping outlines made a hundred-group level unreadable: all 5565 + // outline pairs crossed. + // + // Duplicating the shared marks removed the obligation, and the level is + // now sized to hold the pack rather than the pack squeezed into the + // level. If territories start overlapping again, either something is + // renormalising the level or the marks have gone back to being shared. + const level = levelOf(zipfReach(60)) + for (let i = 0; i < level.anchors.length; i++) { + for (let j = i + 1; j < level.anchors.length; j++) { + const a = level.anchors[i], b = level.anchors[j] + assert.ok(Math.hypot(a.x - b.x, a.y - b.y) >= a.r + b.r - 1e-9 * (a.r + b.r), + `${a.tag} and ${b.tag} overlap`) + } + } +}) + +test("packSiblings resizes nothing", () => { + // The radius carries a content count, so a packing that adjusted it would + // make the circle state a different number from its label. + for (const n of SIZES) { + const radii = zipfReach(n).map((c) => L.territoryRadius(c.reach)) + const packed = L.packSiblings(radii) + assert.equal(packed.length, n) + for (let i = 0; i < packed.length; i++) assert.equal(packed[i].r, radii[i]) + } +}) + +test("an empty level yields no territories rather than an invented one", () => { + for (const empty of [[], null, undefined]) { + const level = L.layoutAnchors(empty) + assert.deepEqual(level.anchors, []) + assert.deepEqual(level.byTag, {}) + assert.equal(level.packExtent, 0) + } + // A group that somehow reports no contents still gets the minimum size: + // it is in the list, so at least one content put it there. + assert.equal(L.layoutAnchors([{ tag: "x", reach: 0 }]).anchors[0].r, L.territoryRadius(1)) +}) + +// --- entering blooms, and the bloom is bounded ------------------------------ + +/** A child level of `contents` contents, with reaches a child could have. */ +function childLevelOf(contents) { + const children = [] + let marks = 0 + for (let i = 0; i < contents && marks < contents * 2; i++) { + const reach = Math.max(1, Math.round(contents / (i + 1))) + children.push({ tag: "c" + i, reach: reach }) + marks += reach + } + return { children: children, marks: marks, level: levelOf(children) } +} + +test("entering a group blooms it, by a factor the model can state", () => { + // The consequence of drawing a content in each of its groups: the level + // behind a circle holds a mark per (content, group) pair, so it needs + // more room than the circle you clicked. + // + // Reserving the room up front cannot avoid this: the factor is + // scale-invariant, so padding every territory scales the level by the + // same amount and cancels out. What CAN be pinned is that the factor is + // stable and derivable, rather than a surprise at run time. Measured on + // the real corpus: 1.55 entering /Arduino, 2.00 entering /OS. + const blooms = [] + for (const contents of [4, 17, 65, 192, 1000]) { + const child = childLevelOf(contents) + const bloom = child.level.levelR / L.territoryRadius(contents) + blooms.push(bloom) + assert.ok(bloom > 1, `a level of ${contents} should need more room than its circle`) + assert.ok(bloom < 3, `bloom ${bloom.toFixed(2)} for ${contents} contents is out of hand`) + // It is the membership multiplier over the packing efficiency, not an + // accident of this fixture's shape. + const predicted = Math.sqrt((child.marks / contents) / 0.78) / L.INNER_FILL + assert.ok(Math.abs(bloom - predicted) < 0.25, + `bloom ${bloom.toFixed(2)} against predicted ${predicted.toFixed(2)}`) + } + // Stable across three orders of magnitude, which is what lets a + // transition be planned rather than discovered. + assert.ok(Math.max(...blooms) - Math.min(...blooms) < 0.1, + `bloom varies from ${Math.min(...blooms).toFixed(2)} to ${Math.max(...blooms).toFixed(2)}`) +}) + +test("entering must NOT re-pack the level: re-packing scatters the siblings", () => { + // Why the bloom has to be a dilation about the entered group rather than + // a fresh packing with one radius grown. + // + // Re-running packSiblings with the entered territory enlarged looks like + // the obvious implementation and is measurably wrong: siblings travel up + // to 1.5 LEVEL RADII -- further than the level is wide -- and up to 11 of + // 23 move INWARD, which is a scatter, not a transition. This is the + // defect the nested-circle model was built to remove, and it comes + // straight back if a bloom is implemented by re-packing. + // + // The measurement lives here so that anyone tempted by the shortcut finds + // the number before shipping it. + const children = zipfReach(24) + const before = levelOf(children) + + let worstTravel = 0, worstInward = 0 + for (let entered = 0; entered < children.length; entered++) { + const after = levelOf(children.map((child, i) => i === entered + ? { tag: child.tag, reach: Math.max(2, Math.round(child.reach * 4.8)) } + : child)) + for (const child of children) { + if (child.tag === children[entered].tag) continue + const was = before.byTag[child.tag], now = after.byTag[child.tag] + worstTravel = Math.max(worstTravel, + Math.hypot(now.x - was.x, now.y - was.y) / before.levelR) + if (Math.hypot(now.x, now.y) < Math.hypot(was.x, was.y) - 1e-9) worstInward++ + } + } + assert.ok(worstTravel > 1, + `re-packing moved siblings only ${worstTravel.toFixed(2)} level radii; if this has ` + + `become small, re-packing may now be an acceptable bloom after all`) + assert.ok(worstInward > 0, "re-packing used to pull siblings inward; it no longer does") +}) + +test("a dilation about the entered group pushes every sibling outward", () => { + // The bloom that IS acceptable: keep the packing, dilate the POSITIONS + // about the entered group's centre by the bloom factor, and leave every + // radius alone (a radius states a content count and must not change + // because the reader navigated). + // + // Distances from the centre of the dilation can only grow, so no sibling + // moves inward, no sibling can be overtaken, and no new overlap can + // appear -- the entered territory grows by exactly the factor the field + // around it spreads by. + const children = zipfReach(24) + const level = levelOf(children) + const bloom = 2.19 + + for (const entered of [0, 3, 12, 23]) { + const centre = level.byTag[children[entered].tag] + const grownR = centre.r * bloom + for (const child of children) { + if (child.tag === children[entered].tag) continue + const was = level.byTag[child.tag] + const moved = { + x: (was.x - centre.x) * bloom + centre.x, + y: (was.y - centre.y) * bloom + centre.y, + r: was.r, + } + const wasOut = Math.hypot(was.x - centre.x, was.y - centre.y) + const nowOut = Math.hypot(moved.x - centre.x, moved.y - centre.y) + assert.ok(nowOut >= wasOut - 1e-9, `${child.tag} moved inward`) + assert.ok(Math.abs(nowOut - wasOut * bloom) < 1e-9, "the push is exactly the bloom") + // The bloomed territory does not swallow it. + assert.ok(nowOut - moved.r - grownR > 0, + `${child.tag} collides with the bloomed territory`) + } + } +}) + +// --- composing levels -------------------------------------------------------- + +test("the ancestor chain composes the blooms, and a point maps through it", () => { + // One step: entering a circle at c with bloom beta maps a parent point p + // to (p - c) * beta, so the entered circle stays put and everything else + // spreads away from it. + const one = L.composeBloom([{ x: 3, y: -4, beta: 2 }]) + assert.deepEqual(one, { scale: 2, x: -6, y: 8 }) + const through = (chain, p) => ({ x: p.x * chain.scale + chain.x, y: p.y * chain.scale + chain.y }) + assert.deepEqual(through(one, { x: 3, y: -4 }), { x: 0, y: 0 }, + "the circle you entered must land on the new origin") + + // Two steps compose as a similarity: scale multiplies, offsets accumulate. + const two = L.composeBloom([{ x: 3, y: -4, beta: 2 }, { x: 10, y: 20, beta: 3 }]) + assert.equal(two.scale, 6) + assert.deepEqual(through(two, { x: 10, y: 20 }), through(one, { x: 0, y: 0 }), + "the grandparent point we entered through must land on the parent's origin") + + assert.deepEqual(L.composeBloom([]), { scale: 1, x: 0, y: 0 }) + assert.deepEqual(L.composeBloom(null), { scale: 1, x: 0, y: 0 }) +}) + +test("a missing or degenerate step truncates the chain instead of inventing one", () => { + // Replaces "a degenerate slot truncates the chain instead of producing + // Infinity". There is no reciprocal left, so Infinity is unreachable; + // what remains worth checking is that a gap STOPS the chain rather than + // being skipped over, which would place an ancestor somewhere + // plausible-looking and wrong. + const good = L.composeBloom([{ x: 1, y: 2, beta: 2 }]) + assert.deepEqual(L.composeBloom([{ x: 1, y: 2, beta: 2 }, null, { x: 100, y: 200, beta: 2 }]), good) + assert.deepEqual(L.composeBloom([{ x: 1, y: 2, beta: 0 }, { x: 5, y: 5, beta: 2 }]), + { scale: 1, x: 0, y: 0 }) +}) + +test("the composed ancestor scale stays modest at depth 5", () => { + // Replaces "ancestor scales stay in a sane range and finite at depth 5". + // The old chain multiplied reciprocal radii and reached the thousands; + // the bloom is bounded near 2.2, so five levels compose to about 50. + const steps = [] + let children = zipfReach(20) + for (let depth = 0; depth < 5; depth++) { + const level = levelOf(children) + // Whichever group is there to enter; deeper levels have fewer. + const slot = level.anchors[Math.min(3, level.anchors.length - 1)] + const child = childLevelOf(slot.reach) + steps.push({ x: slot.x, y: slot.y, beta: child.level.levelR / slot.r }) + children = child.children + } + const chain = L.composeBloom(steps) + assert.ok(Number.isFinite(chain.scale) && Number.isFinite(chain.x) && Number.isFinite(chain.y)) + assert.ok(chain.scale > 1 && chain.scale < 200, + `depth 5 composes to ${chain.scale.toFixed(1)}, which is no longer modest`) +}) + +// --- determinism ------------------------------------------------------------- + +test("the absolute scale is deterministic and free of hidden state", () => { + const children = zipfReach(40) + const a = L.layoutAnchors(children) + assert.deepEqual(L.layoutAnchors(children), a) + assert.deepEqual(L.layoutAnchors(JSON.parse(JSON.stringify(children))), a) +}) + +test("the absolute scale uses no clock and no randomness", () => { + const realRandom = Math.random + const realNow = Date.now + Math.random = () => { throw new Error("Math.random must not be reachable") } + Date.now = () => { throw new Error("Date.now must not be reachable") } + try { + L.territoryRadius(50) + L.spreadForCell(9) + L.levelRadiusFor(30, 4) + L.layoutAnchors(zipfReach(50)) + L.composeBloom([{ x: 1, y: 1, beta: 2 }]) + } finally { + Math.random = realRandom + Date.now = realNow + } +}) + +// --- the boundary, and the camera that keeps it still ----------------------- + +test("a level's boundary contains every one of its marks", () => { + // The invariant that was already established for a GROUP's contour and + // then never applied to the level's own boundary. Carrying the clicked + // outline through left 51 of 85 marks outside it: the outline describes + // the parent's `count` marks inside a territory of radius r, while the + // level holds `reach` marks over about 2.2r. + for (const [groups, contents] of [[3, 9], [21, 29], [106, 192], [60, 400]]) { + const marked = markedLevel(groups, contents) + const hull = L.radialHull(marked.marks, L.territoryRadius(1)) + const ring = L.hullRing(hull, 0, 0) + for (const mark of marked.marks) { + assert.ok(L.pointInPolygon([ring], mark.x, mark.y), + `${groups} groups: a mark at ${mark.x},${mark.y} is outside its own level`) + // Its CARD too, not just its centre. A card is 2.12 x 1.50 where + // the disc was 2 x 2, so it reaches 0.06 further sideways -- if + // the hull's pad did not cover that, a card at the rim would hang + // out of the boundary that is supposed to contain the level. + for (const [sx, sy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { + const cx = mark.x + (sx * L.CARD_W) / 2 + const cy = mark.y + (sy * L.CARD_H) / 2 + assert.ok(L.pointInPolygon([ring], cx, cy), + `${groups} groups: a card corner at ${cx.toFixed(2)},` + + `${cy.toFixed(2)} hangs outside its own level`) + } + } + } +}) + +test("the boundary is a soft shape, neither a circle nor a starfish", () => { + // A metaball cannot do this job: one field over a whole level cannot be + // sampled finely enough. Measured on the root, a level-wide contour on + // the 25x25 grid left 135 of 411 marks outside and reported between 1 and + // 34 separate bodies as the kernel changed -- grid aliasing, not + // geometry. A radial hull has neither failure mode, but it needs a floor: + // without one a sparse level collapsed to spikes reaching the centre. + for (const [groups, contents] of [[6, 25], [21, 85], [106, 411]]) { + const marked = markedLevel(groups, contents) + const hull = Array.from(L.radialHull(marked.marks, L.territoryRadius(1))) + const peak = Math.max(...hull), dip = Math.min(...hull) + assert.ok(dip / peak >= 0.55, + `${groups} groups: the outline dips to ${(dip / peak).toFixed(2)} of its reach -- a starfish`) + assert.ok((peak - dip) / peak > 0.02, + `${groups} groups: the outline is within 2% of a circle`) + } +}) + +test("entering keeps every point still: cam' = (cam-c)*beta, k' = k/beta", () => { + // The identity I deleted once, on the mistaken grounds that an absolute + // scale had made it unnecessary. It removes the RENORMALISATION, not the + // need to follow a moving origin: without this the picture jumped 264 px + // on entering and 118 px on leaving, and it lived in the renderer where + // `node --test` could not see it. + const view = 700 + const camera = { x: 12.5, y: -4.25, scale: 5.0625 } + const centre = { x: -11.79, y: 5.99, r: 20.96 } + const beta = 1.553 + const screen = (cam, p) => view + (p - cam) * cam.scale + + const after = L.bloomCamera(camera, centre, beta, "in") + assert.ok(after) + // Every parent point lands where it was, expressed in the child's space. + for (const p of [{ x: -11.79, y: 5.99 }, { x: 30, y: -20 }, { x: 0, y: 0 }, { x: 44, y: 44 }]) { + const q = { x: (p.x - centre.x) * beta, y: (p.y - centre.y) * beta } + const wasX = view + (p.x - camera.x) * camera.scale + const nowX = view + (q.x - after.x) * after.scale + const wasY = view + (p.y - camera.y) * camera.scale + const nowY = view + (q.y - after.y) * after.scale + assert.ok(Math.abs(nowX - wasX) < 1e-9 && Math.abs(nowY - wasY) < 1e-9, + `a parent point moved by ${Math.hypot(nowX - wasX, nowY - wasY)} px`) + } + // And the entered circle's screen radius survives: beta*r * k/beta = r*k. + assert.ok(Math.abs(centre.r * beta * after.scale - centre.r * camera.scale) < 1e-9) + + // Leaving is the exact inverse. + const back = L.bloomCamera(after, centre, beta, "out") + assert.ok(Math.abs(back.x - camera.x) < 1e-9) + assert.ok(Math.abs(back.y - camera.y) < 1e-9) + assert.ok(Math.abs(back.scale - camera.scale) < 1e-9) + + // A bloom that is not a number is refused rather than producing NaN. + for (const bad of [0, -1, NaN, Infinity, undefined]) { + assert.equal(L.bloomCamera(camera, centre, bad, "in"), null) + } + assert.equal(L.bloomCamera(camera, null, beta, "in"), null) +}) + +test("two hulls blend angle by angle, and the ends are exact", () => { + const a = L.radialHull([{ x: 5, y: 0 }, { x: 0, y: 5 }], 2) + const b = L.radialHull([{ x: 20, y: 0 }, { x: 0, y: 20 }], 4) + assert.deepEqual(Array.from(L.blendHulls(a, b, 0)), Array.from(a)) + assert.deepEqual(Array.from(L.blendHulls(a, b, 1)), Array.from(b)) + const mid = L.blendHulls(a, b, 0.5) + for (let i = 0; i < L.HULL_SECTORS; i++) { + assert.ok(Math.abs(mid[i] - (a[i] + b[i]) / 2) < 1e-12) + } + // Out of range is clamped, not extrapolated: a shape must not invert + // because an easing overshot. + assert.deepEqual(Array.from(L.blendHulls(a, b, -0.5)), Array.from(a)) + assert.deepEqual(Array.from(L.blendHulls(a, b, 1.5)), Array.from(b)) +}) + +test("hullRadiusAt is the exact inverse of hullRing", () => { + // The hit test for an ancestor's boundary asks "how far out does the + // outline run in this direction", and the outline is drawn from the same + // array. If the two disagree by even half a sector the band sits beside + // the line the reader is aiming at. + const marked = markedLevel(21, 85) + const hull = L.radialHull(marked.marks, L.territoryRadius(1)) + const ring = L.hullRing(hull, 0, 0) + // hullRing appends a closing copy of the first point; the rest are one + // per sector, in order. + for (let i = 0; i < hull.length; i++) { + const point = ring[i] + assert.equal(L.hullRadiusAt(hull, point.x, point.y), hull[i], + `sector ${i}: the ring's own vertex resolved to a different radius`) + } + // And a point anywhere along a sector's arc still resolves to it, not to + // its neighbour -- the vertex is the middle of the arc, so a quarter of + // a sector either way must stay put. + const sector = (2 * Math.PI) / hull.length + for (let i = 0; i < hull.length; i++) { + const middle = ((i + 0.5) / hull.length) * 2 * Math.PI + for (const offset of [-sector * 0.4, 0, sector * 0.4]) { + const angle = middle + offset + assert.equal( + L.hullRadiusAt(hull, Math.cos(angle) * 10, Math.sin(angle) * 10), + hull[i], `sector ${i} at offset ${offset.toFixed(3)}`) + } + } + assert.equal(L.hullRadiusAt([], 1, 1), null) + assert.equal(L.hullRadiusAt(null, 1, 1), null) +}) + +test("a connector ends ON the card's edge, never under its text", () => { + // The line from a mark to the detail card has to stop at the card, or it + // would draw ink across the title and summary it is pointing at. + const card = { x: 100, y: 200, w: 200, h: 100 } + const onEdge = (p) => + Math.abs(p.x - card.x) < 1e-9 || Math.abs(p.x - (card.x + card.w)) < 1e-9 + || Math.abs(p.y - card.y) < 1e-9 || Math.abs(p.y - (card.y + card.h)) < 1e-9 + const inside = (p) => + p.x >= card.x - 1e-9 && p.x <= card.x + card.w + 1e-9 + && p.y >= card.y - 1e-9 && p.y <= card.y + card.h + 1e-9 + + // From every direction, the end sits exactly on the boundary. + for (const [x, y] of [[150, 50], [50, 50], [350, 400], [500, 250], [0, 250], + [200, 500], [99, 199], [301, 301]]) { + const end = L.clipToRect(x, y, card.x + card.w / 2, card.y + card.h / 2, card) + assert.ok(end, `no line from ${x},${y}`) + assert.ok(onEdge(end) && inside(end), + `from ${x},${y} the line ended at ${end.x},${end.y}, off the edge`) + } + + // A mark UNDER the card yields no line at all -- there is nothing to + // point out, and a line there would be pure noise on the text. + assert.equal(L.clipToRect(150, 250, 200, 250, card), null) + assert.equal(L.clipToRect(card.x, card.y, 200, 250, card), null) + + // A line that misses the card entirely is returned whole rather than + // being snapped to a phantom crossing. + const past = L.clipToRect(0, 0, 50, 10, card) + assert.deepEqual(past, { x: 50, y: 10 }) + + // No card yet (the DOM has not laid out): the full segment. + assert.deepEqual(L.clipToRect(1, 2, 3, 4, null), { x: 3, y: 4 }) +}) + +test("an off-screen instance is clamped to the viewport edge", () => { + // Every instance of a content gets a line, including the ones the camera + // is not showing: the line stops at the edge and a mark goes there, so + // the reader learns the content also lives that way. + const vp = { x: 0, y: 0, w: 1000, h: 800 } + const from = { x: 500, y: 400 } + for (const [x, y] of [[1500, 400], [500, -300], [-900, 400], [500, 2000], + [3000, 3000], [-100, -100]]) { + const end = L.clampToRect(from.x, from.y, x, y, vp) + assert.ok(end, `no clamp for ${x},${y}`) + const onEdge = Math.abs(end.x) < 1e-6 || Math.abs(end.x - vp.w) < 1e-6 + || Math.abs(end.y) < 1e-6 || Math.abs(end.y - vp.h) < 1e-6 + assert.ok(onEdge, `${x},${y} clamped to ${end.x},${end.y}, not on the edge`) + // And it stays on the ray, so the edge mark points the right way. + const t = Math.abs(x - from.x) > Math.abs(y - from.y) + ? (end.x - from.x) / (x - from.x) + : (end.y - from.y) / (y - from.y) + assert.ok(t >= 0 && t <= 1, `clamp left the segment (t=${t})`) + } + // Inside the viewport there is nothing to clamp. + assert.equal(L.clampToRect(500, 400, 900, 700, vp), null) + assert.equal(L.clampToRect(500, 400, 900, 700, null), null) +}) + +test("the connector geometry uses no clock and no randomness", () => { + const realRandom = Math.random + const realNow = Date.now + Math.random = () => { throw new Error("Math.random must not be reachable") } + Date.now = () => { throw new Error("Date.now must not be reachable") } + try { + L.clipToRect(0, 0, 10, 10, { x: 5, y: 5, w: 10, h: 10 }) + L.clampToRect(0, 0, 100, 100, { x: 0, y: 0, w: 10, h: 10 }) + } finally { + Math.random = realRandom + Date.now = realNow + } +}) + +test("the hull and the camera use no clock and no randomness", () => { + const realRandom = Math.random + const realNow = Date.now + Math.random = () => { throw new Error("Math.random must not be reachable") } + Date.now = () => { throw new Error("Date.now must not be reachable") } + try { + const hull = L.radialHull([{ x: 3, y: 4 }, { x: -5, y: 1 }], 2) + L.hullRing(hull, 0, 0) + L.radialHullOfRings([L.hullRing(hull, 0, 0)], 0, 0) + L.blendHulls(hull, hull, 0.5) + L.bloomCamera({ x: 1, y: 2, scale: 3 }, { x: 0, y: 0 }, 2, "in") + } finally { + Math.random = realRandom + Date.now = realNow + } +}) + +// --- the card a content grows into ------------------------------------------ + +test("the card's diagonal is exactly CONTENT_PITCH, so cards cannot overlap", () => { + // The whole reason there is no collision test. Two axis-aligned w x h + // rectangles whose centres are d apart at angle t miss each other when + // d|cos t| >= w OR d|sin t| >= h; holding for EVERY t requires the + // diagonal to fit. placeMarks guarantees d >= CONTENT_PITCH, so a card + // whose diagonal IS CONTENT_PITCH can never reach a neighbour's. + assert.ok(Math.abs(Math.hypot(L.CARD_W, L.CARD_H) - L.CONTENT_PITCH) < 1e-12, + `diagonal ${Math.hypot(L.CARD_W, L.CARD_H)}, pitch ${L.CONTENT_PITCH}`) + // Silver ratio, landscape. Portrait gives 7 CJK characters per line at a + // readable height, which is not a Japanese title. + assert.ok(Math.abs(L.CARD_W / L.CARD_H - Math.SQRT2) < 1e-15) + assert.ok(L.CARD_W > L.CARD_H, "landscape") + // And it introduces no constant of its own: both sides come from the pitch. + assert.ok(Math.abs(L.CARD_H - L.CONTENT_PITCH / Math.sqrt(3)) < 1e-12) +}) + +test("no two cards overlap on the real-shaped mark sets", () => { + // The guarantee above, exercised against actual placements rather than + // trusted. An overlap here would mean the pitch guarantee had broken. + for (const [groups, contents] of [[3, 9], [21, 29], [106, 192], [60, 400]]) { + const marked = markedLevel(groups, contents) + const marks = marked.marks + for (let i = 0; i < marks.length; i++) { + for (let j = i + 1; j < marks.length; j++) { + const dx = Math.abs(marks[i].x - marks[j].x) + const dy = Math.abs(marks[i].y - marks[j].y) + assert.ok(dx >= L.CARD_W - 1e-9 || dy >= L.CARD_H - 1e-9, + `${groups} groups: cards ${i} and ${j} overlap ` + + `(dx ${dx.toFixed(3)} < ${L.CARD_W.toFixed(3)} and ` + + `dy ${dy.toFixed(3)} < ${L.CARD_H.toFixed(3)})`) + } + } + } +}) + +test("the ceiling is where a card holds a full excerpt", () => { + // Replaces "a content is never wider than 56 px". That rule was right + // about a DISC -- text does not scale with the camera, so magnifying a + // circle buys nothing -- and wrong about a card, whose whole point is + // the text. + const ceiling = L.zoomCeiling() + assert.ok(Math.abs(ceiling - L.CARD_FULL_PX / L.CARD_H) < 1e-12) + const full = L.cardFor(ceiling) + assert.ok(Math.abs(full.h - L.CARD_FULL_PX) < 1e-9) + assert.ok(full.showTitle && full.titleLines === 2, "two title lines at the ceiling") + assert.ok(full.showParent, "and where the content lives") + assert.ok(full.summaryLines >= 4, `and an excerpt, got ${full.summaryLines} lines`) + // Wide enough to read: a 12 px CJK glyph is about 12 px, so 16 per line. + assert.ok(full.usableW / 12 >= 14, `${(full.usableW / 12).toFixed(0)} CJK chars per line`) +}) + +test("what a card shows grows monotonically with its size", () => { + // No tiers and no cross-fades: lines appear one at a time as the room + // arrives. Anything that goes backwards would read as a glitch. + let previous = { lines: -1, titleLines: 0, summaryLines: -1 } + for (let k = 1; k <= L.zoomCeiling(); k += 0.5) { + const card = L.cardFor(k) + assert.ok(card.lines >= previous.lines, `lines went backwards at k=${k}`) + assert.ok(card.summaryLines >= previous.summaryLines, `summary shrank at k=${k}`) + assert.ok(card.round >= 0 && card.round <= 1) + // The parent never appears before the title, nor the summary before + // the parent: dropping from the bottom keeps the identifying line. + if (card.showParent) assert.ok(card.showTitle, `parent without a title at k=${k}`) + if (card.summaryLines > 0) assert.ok(card.showParent, `summary without a parent at k=${k}`) + previous = card + } + // A mark too small for any text is still a circle, and one at the + // ceiling is fully a card. + assert.equal(L.cardFor(10).round, 1) + assert.equal(L.cardFor(L.zoomCeiling()).round, 0) + assert.equal(L.cardFor(10).showTitle, false) +}) + +test("going in by occupancy is reachable before the ceiling is", () => { + // The claim C5 rests on: a group the reader is heading into crosses + // ZOOM_ENTER_OCCUPANCY at a scale they can actually reach by zooming, + // rather than only at the ceiling. If this fails, the ceiling is again + // the only way in -- and at 93 px per content radius that is 6.8x past + // the fit, far too much wheel-work to be a gesture. + // + // The two constants live in TagMap.js because they are camera policy, + // not geometry; they are restated here, and the assertion is about the + // GEOMETRY they act on -- how big territoryRadius makes a group. + const ENTER_OCCUPANCY = 0.62 + const side = 838 // the short side of a 1521x838 viewport + const ceiling = L.zoomCeiling() + const enterK = (r) => (ENTER_OCCUPANCY * side) / (2 * r) + + // Where the limit actually falls. A single-content group is too small + // to fill 62% of the screen before the ceiling stops the camera, so it + // keeps only the push and the tap. Two contents is enough. Pinned as a + // number so a change to CARD_FULL_PX or PACK_K that moves the boundary + // is a visible decision rather than a silent loss of the gesture. + assert.ok(enterK(L.territoryRadius(1)) > ceiling, + "a one-content group is not expected to be enterable by occupancy") + assert.ok(enterK(L.territoryRadius(2)) <= ceiling, + `a two-content group needs k=${enterK(L.territoryRadius(2)).toFixed(1)},` + + ` past the ceiling ${ceiling.toFixed(1)}`) + + for (const direct of [0, 4]) { + for (const name of Object.keys(DISTRIBUTIONS)) { + const level = levelOf(DISTRIBUTIONS[name](12), direct) + const fit = (side * 0.82) / (2 * level.levelR) + for (const child of level.anchors) { + if (child.reach < 2) continue + const at = enterK(child.r) + assert.ok(at <= ceiling, + `${name}/reach ${child.reach}: needs k=${at.toFixed(1)}` + + ` but the ceiling is ${ceiling.toFixed(1)}`) + // And it must not be satisfied ALREADY at the fit, or the + // level would drag the reader in the moment they zoom at + // all. The one exception is a group that essentially IS the + // level: `extreme` gives one group 1000 of 1011 contents, so + // it fills the screen as soon as the level does, and going + // in is where a zoom-in wants to go anyway. The dwell, the + // cooldown and the per-burst cap bound the chaining. + const isTheWholeLevel = child.r / level.levelR > 0.7 + if (!isTheWholeLevel) { + assert.ok(at > fit, + `${name}/reach ${child.reach}: k=${at.toFixed(1)} is at` + + ` or below the fit ${fit.toFixed(1)}`) + } + } + } + } +}) + +test("nearRect includes exactly the margin it is given", () => { + const view = { x: 0, y: 0, w: 800, h: 600 } + // Dead centre, and just inside each edge. + assert.equal(L.nearRect(400, 300, 0, 0, view), true) + assert.equal(L.nearRect(0, 0, 0, 0, view), true) + assert.equal(L.nearRect(800, 600, 0, 0, view), true) + // With no margin, one pixel past an edge is out. + assert.equal(L.nearRect(-1, 300, 0, 0, view), false) + assert.equal(L.nearRect(801, 300, 0, 0, view), false) + assert.equal(L.nearRect(400, -1, 0, 0, view), false) + assert.equal(L.nearRect(400, 601, 0, 0, view), false) + // The margin is inclusive at its own boundary, and only that far. This is + // the bit the renderer relies on: a mark exactly a card away is still + // drawn, so it must still be asked about. + assert.equal(L.nearRect(-198, 300, 198, 140, view), true) + assert.equal(L.nearRect(-198.01, 300, 198, 140, view), false) + assert.equal(L.nearRect(400, 740, 198, 140, view), true) + assert.equal(L.nearRect(400, 740.01, 198, 140, view), false) +}) + +test("a smaller fetch margin leaves marks drawn and unnamed", () => { + // The defect this predicate exists to prevent, as arithmetic: the card + // pass expands by the card, so a mark between the two old margins was + // drawn (card margin) and not asked about (60 px). Measured 2 of the 24 + // on screen in the ordinary /Arduino view. + const view = { x: 0, y: 0, w: 1521, h: 837 } + const card = { w: 198, h: 140 } + const between = { x: -100, y: 400 } // 100 px out: inside 198, outside 60 + assert.equal(L.nearRect(between.x, between.y, card.w, card.h, view), true, + "the card pass draws it") + assert.equal(L.nearRect(between.x, between.y, 60, 60, view), false, + "the old fetch margin did not ask about it") + // Which is why both passes now call this with the same w and h. +}) + +test("the card uses no clock and no randomness", () => { + const realRandom = Math.random + const realNow = Date.now + Math.random = () => { throw new Error("Math.random must not be reachable") } + Date.now = () => { throw new Error("Date.now must not be reachable") } + try { + L.cardFor(0) + L.cardFor(45) + L.cardFor(L.zoomCeiling()) + L.zoomCeiling() + } finally { + Math.random = realRandom + Date.now = realNow + } + // Degenerate inputs give a degenerate card, not NaN. + for (const bad of [0, -5, NaN, undefined]) { + const card = L.cardFor(bad) + assert.ok(Number.isFinite(card.w) && Number.isFinite(card.h)) + assert.equal(card.showTitle, false) + } +}) diff --git a/Frontend/contents-viewer.php b/Frontend/contents-viewer.php index 4e30076..3619cf7 100644 --- a/Frontend/contents-viewer.php +++ b/Frontend/contents-viewer.php @@ -331,7 +331,6 @@ . $_SERVER["HTTP_HOST"] . CVUtils\CreateContentHREF($contentPath); $vars['htmlLang'] = $vars['layerName']; -$vars['otpRequired'] = true; $vars['additionalHeadScript'] .= ' ' inside data cannot break out of the inline element. +// Buffered: this runs before the doctype, and index.php's error handler +// echoes its diagnostics. Anything printed here would land in front of +// and put the document into quirks mode, which breaks a +// template built on height:100% / position:fixed. Log it instead. +ob_start(); +$initialStateJson = TagmapQuery\responseJson( + $dbContext, + $vars['rootDirectory'], + $vars['layerName'], + $layerSuffix, + $tagPathParts, + TagmapQuery\SCOPE_DIRECT, // the population the map draws + 0, + 20 +); +$strayOutput = ob_get_clean(); +if ($strayOutput !== '') { + logger()->error("tag-viewer: output before doctype was discarded:\n" . $strayOutput); } - -$vars['contentBody'] = $body; - - -// navigator 設定 -$vars['navigator'] = createNavi($eachSelectedTaggedPaths, $tag2path, $path2tag, $vars['rootDirectory'], $vars['layerName']); +$vars['tagmapInitialStateJson'] = str_replace('<', chr(0x5C) . 'u003C', $initialStateJson); // ビルド時間計測 終了 +// tagmap-page.php renders no footer, so there is no pageBuildReport to fill +// in; the slow-page warning below is what this measurement is for. $stopwatch->Stop(); -$vars['pageBuildReport']['times']['build']['ms'] = $stopwatch->Elapsed() * 1000; if ($stopwatch->Elapsed() > 1.5) { logger()->warning( @@ -471,277 +155,4 @@ } $vars['metaRobots'] = 'noindex, follow'; -require(FRONTEND_DIR . '/viewer.php'); - - -/** - * ['pathA' => any, 'pathB' => any, ...] - * - * @param array|null $source - * ['pathA' => any, 'pathB' => any, ...] - * @param string[] $selectorTags - * @param array $tag2path - * @param array $path2tag - * @return array - */ -function selectTaggedPaths($source, $selectorTags, $tag2path, $path2tag) -{ - $selectedPaths = []; - foreach ($selectorTags as $tag) { - if (isset($tag2path[$tag])) { - $selectedPaths += $tag2path[$tag]; - } - } - - if (is_null($source)) { - return $selectedPaths; - } - - return array_intersect_key($source, $selectedPaths); -} - - -/** - * @param array|null $source - * @param string[] $selectorTags - * @param SearchEngine\Index $index - * @return array - */ -function findTagSuggestedPaths($source, $selectorTags, $index) -{ - $suggestions = []; - foreach ($selectorTags as $tag) { - $suggestions = array_merge( - $suggestions, - $index->search($tag) - ); - } - - foreach ($suggestions as $i => $suggested) { - if ($suggested['score'] < 0.75) { - unset($suggestions[$i]); - } - } - - sortSuggestions($suggestions); - - $selectedPaths = []; - foreach ($suggestions as $suggested) { - $selectedPaths[$suggested['id']] = $suggested['score']; - } - - if (is_null($source)) { - return $selectedPaths; - } - return array_intersect_key($selectedPaths, $source); -} - - -/** - * ['tagA' => any, 'tagB' => any, ...] - * - * @param array $paths - * ['pathA' => any, 'pathB' => any, ...] - * @param array $path2tag - * @return array - */ -function getUnionTags($paths, $path2tag) -{ - $union = []; - foreach ($paths as $path => $_) { - if (array_key_exists($path, $path2tag)) { - $union += $path2tag[$path]; - } - } - return $union; -} - - -/** - * @param array $eachSelectedTaggedPaths - * [ - * [ - * 'selectors' => ['tagA', 'tagB', ...], - * 'selected' => ['pathA' => any, 'pathB' => any, ...] - * ], ... - * ] - * @param array $tag2path - * @param array $path2tag - * @param string $rootDirectory - * @param string $layerName - * @return string - */ -function createNavi($eachSelectedTaggedPaths, $tag2path, $path2tag, $rootDirectory, $layerName) -{ - $navi = ''; - return $navi; -} - - -function sortSuggestions(&$suggestions) -{ - uasort($suggestions, function ($a, $b) { - if ($a['score'] == $b['score']) { - return 0; - } - return ($a['score'] < $b['score']) ? 1 : -1; - }); -} - - -function createTagGroupsElement($tagGroups, $contentMap, $tagPathParts, $rootDirectory, $layerName) -{ - $html = ''; - - $groups = $tagGroups['tags']; - $compact = []; - - foreach ($groups as $tag => $paths) { - $keys = array_keys($groups, $paths); - $compact[implode(', ', $keys)] = ['tagPathParts' => $keys, 'paths' => $paths]; - } - - foreach ($compact as $name => $desc) { - $html .= '
'; - $tagHref = CVUtils\CreateTagMapHREF(array_merge($tagPathParts, [$desc['tagPathParts']]), $rootDirectory, $layerName); - $html .= CVUtils\CreateTagCard($name, $tagHref); - $html .= createContentCardsElement($desc['paths'], $contentMap); - $html .= '
'; - } - return $html; -} - - -function createTagCardsElement($tags, $tagPathParts, $rootDirectory, $layerName) -{ - $html = ''; - if (!empty($tags)) { - $html .= '
'; - foreach ($tags as $tag => $count) { - $tagHref = CVUtils\CreateTagMapHREF(array_merge($tagPathParts, [[$tag]]), $rootDirectory, $layerName); - $html .= CVUtils\CreateTagCard("$tag ($count)", $tagHref, true, true); - } - $html .= '
'; - } - return $html; -} - - -function createContentCardsElement($paths, $contentMap) -{ - $html = ''; - foreach ($paths as $path => $_) { - if (!isset($contentMap[$path]['content'])) continue; - $content = $contentMap[$path]['content']; - $parent = $content->parent(); - $text = CVUtils\GetDecodedText($content); - $href = CVUtils\CreateContentHREF($content->path); - $title = ''; - - $title .= NotBlankText([$content->title, basename($content->path)]) - . ($parent === false ? '' : ' | ' . NotBlankText([$parent->title, basename($parent->path)])); - - $footer = ''; - if ($contentMap[$path]['suggested']) { - $footer = '
' . Localization\Localize('tag-viewer.suggested', 'Suggested'); - } - $html .= CVUtils\CreateContentCard($title, $text['summary'], $href, $footer); - } - return $html; -} - - -/** - * - * [ - * 'non' => ['path'=>true, 'path'=>true, ...], - * 'tags' => [ - * 'tag' => ['path' => true], - * ... - * ] - * ] - * - * @param array $contentMap - * [ - * 'path' => Any, 'path' => Any, ... - * ] - * @param array $path2tag - * @param array $selectedTags - * @return array - */ -function createTagGroups($contentMap, $path2tag, $selectedTags) -{ - $tagGroups = ['non' => [], 'tags' => []]; - - if (!empty($contentMap)) { - $unionTags = getUnionTags($contentMap, $path2tag); - $unionTags = array_diff_key($unionTags, $selectedTags); - - foreach ($contentMap as $path => $_) { - $tagGroups['non'][$path] = true; - } - - foreach ($contentMap as $path => $_) { - foreach ($path2tag[$path] ?? [] as $tag => $__) { - if (!isset($unionTags[$tag])) continue; - $tagGroups['tags'][$tag][$path] = true; - unset($tagGroups['non'][$path]); - } - } - } - return $tagGroups; -} +require(FRONTEND_DIR . '/tagmap-page.php'); diff --git a/Frontend/tagmap-page.php b/Frontend/tagmap-page.php new file mode 100644 index 0000000..dc6915c --- /dev/null +++ b/Frontend/tagmap-page.php @@ -0,0 +1,125 @@ +canonicalize()->split()[1])[0]; + +?> + + + + + + + + + <?= H($vars['pageTitle']) ?> + + + + + + + + + + + + + + + + "> + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + 0) : ?> +
+ + +
+ + + + diff --git a/Frontend/viewer.php b/Frontend/viewer.php index 2fcf4bc..8c82dc3 100644 --- a/Frontend/viewer.php +++ b/Frontend/viewer.php @@ -18,7 +18,6 @@ * * オプション * $vars['contentPath'] - * $vars['otpRequired'] = true * $vars['htmlLang'] = '' * $vars['canonialUrl'] = '' * $vars['additionalHeadScript'] = '' @@ -79,10 +78,6 @@ - - - - "> @@ -114,6 +109,9 @@ + + + diff --git a/Locales/ja/translations.tag-viewer.json b/Locales/ja/translations.tag-viewer.json index c87f1fe..0fd4bf2 100644 --- a/Locales/ja/translations.tag-viewer.json +++ b/Locales/ja/translations.tag-viewer.json @@ -1,8 +1,3 @@ { - "narrowDown": "さらに絞り込む", - "foundNContents": "「{0}」内で, {1}件のコンテンツが見つかりました.", - "foundNContentsSuggestedNContents": "「{0}」内で, {1}件のコンテンツが見つかり, {2}件のコンテンツが提案されています.", - "suggestedNContents": "「{0}」内で, {1}件のコンテンツが提案されています.", - "notFoundContents": "「{0}」内で, コンテンツが見つかりませんでした.", - "suggested": "提案" -} \ No newline at end of file + "requiresJs": "タグマップの表示にはJavaScriptが必要です。" +} diff --git a/Module/AccessGate.php b/Module/AccessGate.php new file mode 100644 index 0000000..01d4d5b --- /dev/null +++ b/Module/AccessGate.php @@ -0,0 +1,223 @@ +notice('AccessGate: challenge issued for ' . ($_SERVER['REMOTE_ADDR'] ?? '-') . ' ' . $path); + } + self::respondChallenge(); + } + + /** + * Issues a proof-of-work seed bound to the given client address. + * Returns false when the gate is not configured or the address is invalid. + */ + public static function issueSeed(string $remoteAddr): string|false + { + if (!self::isConfigured()) { + return false; + } + $ipKey = self::ipKey($remoteAddr); + if ($ipKey === null) { + return false; + } + $expiry = time() + self::tokenTtl(); + $bits = self::powBits(); + $mac = self::sign(self::TOKEN_VERSION . "|{$ipKey}|{$expiry}|{$bits}"); + return self::TOKEN_VERSION . ".{$expiry}.{$bits}.{$mac}"; + } + + /** + * Verifies a token for the given client address. + * Checks, cheapest first: format, expiry, difficulty, HMAC (IP-bound), + * then the proof-of-work itself. + */ + public static function verifyToken(string $token, string $remoteAddr): bool + { + if (!self::isConfigured()) { + return true; // fail-open by design + } + if (!preg_match('/^v1\.(\d{10})\.(\d{1,2})\.([0-9a-f]{32})\.(\d{1,12})$/', $token, $m)) { + return false; + } + if ((int)$m[1] < time()) { + return false; + } + // Raising ACCESS_GATE_POW_BITS instantly invalidates weaker tokens. + if ((int)$m[2] < self::powBits()) { + return false; + } + $ipKey = self::ipKey($remoteAddr); + if ($ipKey === null) { + return false; + } + $expected = self::sign(self::TOKEN_VERSION . "|{$ipKey}|{$m[1]}|{$m[2]}"); + if (!hash_equals($expected, $m[3])) { + return false; + } + return self::hasLeadingZeroBits(hash('sha256', $token, true), (int)$m[2]); + } + + /** + * HMAC-SHA256 signing primitive (truncated to 128 bits, hex). + * Exposed for reuse by other one-shot token schemes. + */ + public static function sign(string $message): string + { + return substr(hash_hmac('sha256', $message, ACCESS_GATE_SECRET), 0, 32); + } + + /** + * Key identifying the client network location: + * IPv4 -> full /32; IPv6 -> /64 prefix (absorbs privacy-address rotation); + * IPv4-mapped IPv6 -> the embedded IPv4 (otherwise all mapped clients + * would collapse into a single /64). Returns null for unparsable input. + */ + private static function ipKey(string $remoteAddr): ?string + { + $bin = @inet_pton($remoteAddr); + if ($bin === false) { + return null; + } + if (strlen($bin) === 4) { + return bin2hex($bin); + } + if (strlen($bin) === 16) { + if (substr($bin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") { + return bin2hex(substr($bin, 12, 4)); + } + return bin2hex(substr($bin, 0, 8)); + } + return null; + } + + private static function isProtectedPath(string $path): bool + { + foreach (self::protectedUris() as $needle) { + if ($needle !== '' && stripos($path, $needle) !== false) { + return true; + } + } + return false; + } + + private static function hasLeadingZeroBits(string $rawHash, int $bits): bool + { + $fullBytes = $bits >> 3; + for ($i = 0; $i < $fullBytes; $i++) { + if ($rawHash[$i] !== "\x00") { + return false; + } + } + $rest = $bits & 7; + if ($rest > 0 && (ord($rawHash[$fullBytes]) >> (8 - $rest)) !== 0) { + return false; + } + return true; + } + + /** + * Serves the challenge page inline at the requested URL and exits. + * 428 makes gate re-challenges distinguishable from the static rewrite + * (200) in access logs, and is treated as an error by API clients. + */ + private static function respondChallenge(): never + { + http_response_code(428); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store'); + header('X-Robots-Tag: noindex'); + $file = defined('ROOT_DIR') ? ROOT_DIR . '/' . self::CHALLENGE_PATH : ''; + if ($file === '' || !is_file($file) || @readfile($file) === false) { + echo '

Please reload this page.

'; + } + exit; + } +} diff --git a/Module/Authenticator.php b/Module/Authenticator.php index 6fa9dfd..8cd57f9 100644 --- a/Module/Authenticator.php +++ b/Module/Authenticator.php @@ -210,47 +210,8 @@ public function verifyDigest(string $header): string|false return ($validResponse && $validNonce) ? $params['username'] : false; } - // --- OTP --- - - /** - * @param int $expires 有効期限(秒) - */ - public function generateOtp(int $expires): string - { - $newOtp = bin2hex(random_bytes(32)); - - $cache = new Cache(); - $cache->connect('otps'); - $cache->lock(LOCK_EX); - $cache->fetch(); - $otps = $cache->data['otps'] ?? []; - foreach ($otps as $otp => $exp) { - if ($exp < time()) { - unset($otps[$otp]); - } - } - $otps[$newOtp] = time() + $expires; - - $cache->data['otps'] = $otps; - $cache->apply(); - $cache->unlock(); - $cache->disconnect(); - - return $newOtp; - } - - public function verifyOtp(string $otp): bool - { - $cache = new Cache(); - $cache->connect('otps'); - $cache->lock(LOCK_SH); - $cache->fetch(); - $otps = $cache->data['otps'] ?? []; - $cache->unlock(); - $cache->disconnect(); - - return isset($otps[$otp]); - } + // NOTE: The former OTP mechanism (generateOtp/verifyOtp) was replaced by + // the stateless AccessGate token (see Module/AccessGate.php). // --- Utility --- diff --git a/Module/CacheStore.php b/Module/CacheStore.php index c05731b..ad0dca6 100644 --- a/Module/CacheStore.php +++ b/Module/CacheStore.php @@ -157,8 +157,12 @@ public static function gc(bool $force = false): void } } + // scandir returns false when the directory is missing, unreadable, or + // not a directory. shuffle() takes an array, so passing that false on + // is a TypeError, which no error handler catches -- garbage collection + // is a background chore and must not be able to end the request. $files = scandir(CACHE_DIR . DIRECTORY_SEPARATOR); - if (!shuffle($files)) return; + if ($files === false || !shuffle($files)) return; $counter = 0; foreach ($files as $file) { if ($counter >= self::GC_MAX_FILE_CRAWL) break; diff --git a/Module/ErrorHandling.php b/Module/ErrorHandling.php index bfc9ef4..715651a 100644 --- a/Module/ErrorHandling.php +++ b/Module/ErrorHandling.php @@ -32,23 +32,29 @@ function StyledErrorHandler($severity, $message, $file, $line) { echo "
{$severityString}: {$message} in {$file} on line {$line}
"; } +/** + * Error handler for Service endpoints. Logs only, never writes to the body. + * + * Services emit machine-readable bodies (mostly JSON), so anything echoed here + * lands in front of the payload and makes it unparseable. set_error_handler + * also bypasses display_errors, so echoing would leak absolute paths and line + * numbers to every client regardless of configuration. Read OutputLog.txt. + */ function PlainErrorHandler($severity, $message, $file, $line) { // error was suppressed with the @-operator. if (!(error_reporting() & $severity)) return; - - global $_SEVERITY_TO_STRING; - $severityString = $_SEVERITY_TO_STRING[$severity] ?? 'E_UNKNOWN'; OutputDebugLog($severity, $message, $file, $line); - echo "
{$severityString}: {$message} in {$file} on line {$line}
"; } function OutputDebugLog($severity, $message, $file, $line) { global $_SEVERITY_TO_STRING; $severityString = $_SEVERITY_TO_STRING[$severity] ?? 'E_UNKNOWN'; + // Not set under CLI, and a missing key here would raise inside the handler. + $requestUri = $_SERVER['REQUEST_URI'] ?? '(none)'; \logger()->critical(" RuntimeError Occurred: - REQUEST_URI: {$_SERVER['REQUEST_URI']} + REQUEST_URI: {$requestUri} Severity : {$severityString} Message : {$message} File : {$file} diff --git a/Module/TagmapQuery.php b/Module/TagmapQuery.php new file mode 100644 index 0000000..b7179f6 --- /dev/null +++ b/Module/TagmapQuery.php @@ -0,0 +1,1059 @@ + `[['A', 'B'], ['C']]`; `''`/`'/'` -> `[]`. + * Tags are trimmed; empty tags and empty segments are dropped. + * + * @return array + */ +function parseTagPath(string $tagPath): array +{ + $tagPath = trim($tagPath, '/'); + if ($tagPath === '') { + return []; + } + $parts = []; + foreach (explode('/', $tagPath) as $segment) { + $tags = array_values(array_filter( + array_map('trim', explode(',', $segment)), + fn(string $tag) => $tag !== '' + )); + if ($tags !== []) { + $parts[] = $tags; + } + } + return $parts; +} + +/** + * Canonical form of a tag path: per segment, unknown tags are dropped, exact + * duplicates removed and the rest sorted ascending with the same collation + * as the tag list display (natural order, case-insensitive); segments left + * empty are dropped. Segments are not deduplicated across each other + * (repeats legitimately narrow fuzzy-augmented selections). + * + * @param array $parts + * @return array{parts: array, canonical: string, changed: bool} + */ +function canonicalize(array $parts, array $tag2path, string $originalTagPath): array +{ + $canonicalParts = []; + foreach ($parts as $segment) { + $tags = array_values(array_unique(array_filter( + $segment, + fn(string $tag) => array_key_exists($tag, $tag2path) + ))); + usort($tags, 'strnatcasecmp'); + if ($tags !== []) { + $canonicalParts[] = $tags; + } + } + $canonical = canonicalTagPath($canonicalParts); + return [ + 'parts' => $canonicalParts, + 'canonical' => $canonical, + 'changed' => trim($originalTagPath, '/') !== $canonical, + ]; +} + +/** + * Inverse of parseTagPath, without percent-encoding: `[['A','B'],['C']]` -> + * `'A,B/C'`; `[]` -> `''`. (URL building goes through CreateTagMapHREF.) + * + * @param array $parts + */ +function canonicalTagPath(array $parts): string +{ + return implode('/', array_map(fn(array $segment) => implode(',', $segment), $parts)); +} + +/** @param array $parts */ +function withinCaps(array $parts): bool +{ + if (count($parts) > maxDepth()) { + return false; + } + foreach ($parts as $segment) { + if (count($segment) > maxWidth()) { + return false; + } + } + return true; +} + +/** + * Selection pipeline: refines the full-content set segment by segment. + * Requires `$dbContext->LoadMetadata()` and `$dbContext->LoadIndex()`. + * + * @param array $parts + * @return array}> + */ +function select(\ContentDatabaseContext $dbContext, array $parts): array +{ + $tag2path = $dbContext->metadata->data['tag2path'] ?? []; + $path2tag = $dbContext->metadata->data['path2tag'] ?? []; + + $eachSelected = []; + $source = null; + foreach ($parts as $part) { + // Tagged first: PHP's array `+` keeps the LEFT operand's keys, so + // whichever side comes first decides the value of a path found by + // both. The content index includes each content's tags, so searching + // a tag name fuzzy-matches nearly every content already carrying it + // — with the operands the other way round (as the former + // server-rendered view had them) the float score won and virtually + // every content was classified as a name match. Explicit membership + // must win, so `true` means tagged and a float means name-only. + $selected = selectTaggedPaths($source, $part, $tag2path, $path2tag) + + findTagSuggestedPaths($source, $part, $dbContext->index); + $eachSelected[] = ['selectors' => $part, 'selected' => $selected]; + $source = $selected; + } + return $eachSelected; +} + +/** + * The population a selection puts on the map: path => membership, where + * `true` means the content carries a selected tag and a float is a name + * similarity score. + * + * THIS IS THE ONE DEFINITION of "what is in this selection", and it exists + * because there used to be two. membershipManifest() names these paths and + * contentsByKeys() resolves the client's keys back to bodies; when the + * second walked `path2tag` instead (tagged contents only) it could not + * resolve the name matches the first had already put on the map, so those + * marks were drawn and could never be named. Measured on /Arduino: 65 keys + * in the manifest, 57 resolvable, and all 8 that were not were name matches. + * Both callers now read the same array, which makes that class of + * disagreement unrepresentable rather than merely fixed. + * + * At the root there is no selection, so every tagged content is in and every + * membership is `true`. `path2tag`'s values are TAG MAPS, not memberships -- + * handing them straight through would make contentItem() read each one as a + * name match (it tests `!is_bool($membership)`) and report the whole corpus + * as `suggested`. Normalising here, once, is what keeps that trap out of the + * call sites. + * + * @param array}> $eachSelected + * @param array $path2tag + * @return array + */ +function universeOf(array $eachSelected, array $path2tag): array +{ + return $eachSelected === [] + ? array_map(fn() => true, $path2tag) + : end($eachSelected)['selected']; +} + +/** + * The same population, for a caller that holds only the selection. + * + * Requires LoadMetadata(). Loads the index only when there IS a selection to + * resolve: name matching happens inside select(), so the root -- which + * selects nothing -- needs no index at all (measured: `totalContents == + * explicitContents == 192` there, i.e. not one name match to find). + * + * @param array $parts canonical + * @return array + */ +function selectionUniverse(\ContentDatabaseContext $dbContext, array $parts): array +{ + $path2tag = $dbContext->metadata->data['path2tag'] ?? []; + if ($parts === []) { + return universeOf([], $path2tag); + } + $dbContext->LoadIndex(); + return universeOf(select($dbContext, $parts), $path2tag); +} + +/** + * Collapses tags that cover exactly the same contents into one entry. + * + * Two tags that always appear together over this selection carry no + * distinguishing information: shown separately they are several circles the + * reader cannot tell apart, and the same content is counted once per tag so + * the numbers stop adding up. Merged, the group is a single OR segment + * (`A,B`) that selects precisely the set the reader saw. + * + * This is the former server-rendered view's rule (`array_keys($groups, + * $paths)` in its createTagGroupsElement), restated as data rather than + * markup so the map can use it too. + * + * @param array> $tagPaths tag => set of paths + * @return array keyed by 'A,B' + */ +function mergeIdenticalTags(array $tagPaths): array +{ + // Group by the identity of the path set. Sorting the paths makes the key + // independent of the order they were encountered in. + $byShape = []; + foreach ($tagPaths as $tag => $paths) { + $shape = array_keys($paths); + sort($shape); + $byShape[md5(implode("\0", $shape))][] = $tag; + } + + $merged = []; + foreach ($byShape as $tags) { + usort($tags, 'strnatcasecmp'); + // The key doubles as the OR segment, so it must already be in the + // canonical order the URL layer expects (same collation). + $merged[implode(',', $tags)] = [ + 'tags' => $tags, + 'count' => count($tagPaths[$tags[0]]), + ]; + } + return $merged; +} + +/** + * Co-occurrence data around the CURRENT selection. + * coTags: the child groups. Tags on any path of the current selection + * (excluding already-selected ones), with tags covering an + * IDENTICAL set of contents merged into one group (see + * mergeIdenticalTags); a group is keyed by, and navigable as, the + * OR segment `A,B`. + * tags = the tags in the group + * count = |current selection ∩ tag2path[tag]|, identical for + * every tag in the group + * orCount = |parentSource ∩ (lastPart ∪ tags)| (OR-add result) + * Groups with count 0 never appear, so a drill-down can no longer + * dead-end at zero contents. + * + * `count` is a statement about the CURRENT set ("N of the contents + * here carry this tag"), not a prediction of the next view: it is a + * pure tag intersection, while select() also admits fuzzy matches + * (score >= 0.75) once you navigate, so the next total is >= count. + * Present it in the present tense; `stats.explicitContents` lets a + * client explain the gap. An exact per-tag prediction would need a + * fuzzy index search per coTag (up to 200 per response) — too slow. + * + * Root (empty selection): every tag, count = orCount = global. + * chipTags: per-tag contribution counts for the last segment's tags + * (the removable breadcrumb chips; parent-based by design). + * orBase: |parentSource ∩ lastPart| — the fuzzy-blind size that orCount is + * measured from, so `orCount - orBase` is a non-negative delta. + * Comparing orCount against stats.totalContents instead would + * suggest that OR-adding a tag shrinks the set, because that total + * includes fuzzy matches and orCount does not. Null at the root. + * directCount: + * contents of the current selection carrying no unselected tag, so + * they belong under the selection itself rather than to any child + * tag. This is the legacy createTagGroups() 'non' group, computed + * over the WHOLE selection — a client cannot derive it from a page + * of items, nor from coTags (which is capped at MAX_CO_TAGS). + * + * @param array}> $eachSelected + * @param array $parts + * @return array{coTags: array, chipTags: array, orBase: int|null, directCount: int} + */ +function coOccurrence(array $eachSelected, array $parts, array $tag2path, array $path2tag): array +{ + $selectedTags = []; + foreach ($parts as $part) { + foreach ($part as $tag) { + $selectedTags[$tag] = true; + } + } + + $current = $eachSelected === [] ? null : end($eachSelected)['selected']; + $parentSource = count($eachSelected) > 1 ? $eachSelected[count($eachSelected) - 2]['selected'] : null; + $lastPart = $parts === [] ? [] : end($parts); + + // Per candidate tag, WHICH paths of the current selection carry it. The + // sets (not just their sizes) are what lets identical tags be merged + // below, and the same pass yields directCount for free. + $tagPaths = []; + $directCount = 0; + if (!is_null($current)) { + foreach ($current as $path => $_) { + $hasUnselectedTag = false; + foreach ($path2tag[$path] ?? [] as $tag => $__) { + if (!isset($selectedTags[$tag])) { + $tagPaths[$tag][$path] = true; + $hasUnselectedTag = true; + } + } + if (!$hasUnselectedTag) { + $directCount++; + } + } + } else { + foreach ($tag2path as $tag => $paths) { + if (!isset($selectedTags[$tag])) { + $tagPaths[$tag] = $paths; + } + } + } + + $coTags = mergeIdenticalTags($tagPaths); + foreach ($coTags as $key => $group) { + $coTags[$key]['orCount'] = count(selectTaggedPaths( + $parentSource, + array_merge($lastPart, $group['tags']), + $tag2path, + $path2tag + )); + } + // Deterministic order: natural-case key order, then stable sort by count + // desc (PHP sorts are stable since 8.0) — keeps layouts reproducible. + uksort($coTags, 'strnatcasecmp'); + uasort($coTags, fn(array $a, array $b) => $b['count'] <=> $a['count']); + + $chipTags = []; + $orBase = null; + if ($parts !== []) { + foreach ($lastPart as $tag) { + $chipTags[$tag] = count(selectTaggedPaths($parentSource, [$tag], $tag2path, $path2tag)); + } + $orBase = count(selectTaggedPaths($parentSource, $lastPart, $tag2path, $path2tag)); + } + + // Which groups each content belongs to. The client needs this to place + // every content exactly once: with counts alone it cannot tell that the + // same content is being counted by three groups, so it would draw three + // marks for one thing. Built from the sets already computed above, so no + // content file is read and nothing is recounted. + $pathGroups = []; + foreach ($coTags as $key => $group) { + foreach ($tagPaths[$group['tags'][0]] as $path => $_) { + $pathGroups[$path][] = $key; + } + } + + return [ + 'coTags' => $coTags, + 'chipTags' => $chipTags, + 'orBase' => $orBase, + 'directCount' => $directCount, + 'pathGroups' => $pathGroups, + ]; +} + +/** + * A short, stable identity for a content, so the client can tell that a mark + * at one level and a mark at another are the same thing and tween between + * them. The path itself would do, but it more than doubles the manifest + * (measured: 8.8 KB versus 4.1 KB at the root); 48 bits keeps collisions + * around one in ten million at ten thousand contents, and a collision costs + * a tweened mark, not correctness. + */ +function contentKey(string $path): string +{ + return substr(md5($path), 0, 12); +} + +/** Maximum manifest rows. Beyond this the client falls back to anonymous marks. */ +const MAX_MANIFEST = 4000; + +/** + * The membership manifest: one row per content, naming the groups it belongs + * to by their index in `coTags`. + * + * Rows are `[key, [groupIndex, ...]]`. An empty list means the content sits + * directly under the selection. A `-1` means it belongs to a group that fell + * outside MAX_CO_TAGS — kept distinct so those contents are never mistaken + * for direct ones, which would inflate `directContents` all over again. + * + * @param array $universe the paths in scope, in payload order + * @param array $pathGroups path => group keys + * @param string[] $shownKeys group keys in response order (already capped) + * @return array{rows: array, truncated: bool} + */ +function membershipManifest(array $universe, array $pathGroups, array $shownKeys): array +{ + $indexOf = array_flip($shownKeys); + $rows = []; + $truncated = false; + foreach ($universe as $path => $_) { + if (count($rows) >= MAX_MANIFEST) { + $truncated = true; + break; + } + $groups = []; + $hidden = false; + foreach ($pathGroups[$path] ?? [] as $key) { + if (isset($indexOf[$key])) { + $groups[] = $indexOf[$key]; + } else { + $hidden = true; + } + } + sort($groups); + if ($hidden) { + $groups[] = -1; + } + $rows[] = [contentKey($path), $groups]; + } + return ['rows' => $rows, 'truncated' => $truncated]; +} + +/** + * Lexically similar tag suggestions for the last segment (tag names + * themselves can be near-duplicates, e.g. WSL / WSL2), from the + * `.index.tagmap` fuzzy index. Rebuilds that index only when stale against + * `contentsChangedTime` (the single write permitted on this read path). + * Per suggested tag: + * count = |current selection ∩ tag2path[tag]| (AND size; may be 0) + * orCount = |source ∩ (lastPart ∪ tag)| (OR-add result size) + * + * @param string[] $lastPart + * @param array $selectedTags + * @param array|null $source parent selection (null = all) + * @param array|null $current current selection + * @return array + */ +function suggestedTags( + \ContentDatabaseContext $dbContext, + string $rootDirectory, + string $layerSuffix, + array $lastPart, + array $selectedTags, + ?array $source, + ?array $current, + array $tag2path, + array $path2tag +): array { + $tagmapIndexFileName = CONTENTS_HOME_DIR . $rootDirectory . '/.index.tagmap' . $layerSuffix; + $tagMapIndex = new \SearchEngine\Index(); + // When contentsChangedTime is unknown, keep the index we have: rebuilding + // it on every request is a far worse failure than slightly stale tag-name + // suggestions. (The response cache resolves the same ambiguity the other + // way, because there a rebuild costs one request, not one per request.) + $indexTime = @filemtime($tagmapIndexFileName); + if ( + !$tagMapIndex->load($tagmapIndexFileName) + || $indexTime === false + || $indexTime < ($dbContext->metadata->data['contentsChangedTime'] ?? 0) + ) { + $tagMapIndex = new \SearchEngine\Index(); + foreach ($tag2path as $tag => $_) { + $tagMapIndex->register($tag, $tag); + } + $tagMapIndex->apply($tagmapIndexFileName); + } + + $suggestions = []; + foreach ($lastPart as $tag) { + $suggestions = array_merge($suggestions, $tagMapIndex->search($tag)); + } + foreach ($suggestions as $i => $suggested) { + if ($suggested['score'] < 0.5 || array_key_exists($suggested['id'], $selectedTags)) { + unset($suggestions[$i]); + } + } + sortSuggestions($suggestions); + + $result = []; + foreach ($suggestions as $suggested) { + $orPaths = selectTaggedPaths( + $source, + array_merge($lastPart, [$suggested['id']]), + $tag2path, + $path2tag + ); + if (count($orPaths) > 0) { + $andPaths = is_null($current) + ? ($tag2path[$suggested['id']] ?? []) + : array_intersect_key($current, $tag2path[$suggested['id']] ?? []); + $result[$suggested['id']] = [ + 'count' => count($andPaths), + 'orCount' => count($orPaths), + 'score' => $suggested['score'], + ]; + } + } + return $result; +} + +/** + * One page of the matching contents, in deterministic order: explicit + * (user-tagged) matches first, then suggested matches by score descending, + * then by path. Content files are read only for the sliced page. + * Missing contents are skipped (never pruned from metadata on this path). + * + * `$scope` picks the population, because the map draws the two apart: + * SCOPE_DIRECT contents carrying no unselected tag — the legacy + * createTagGroups() 'non' group. These belong to the + * selection itself and are drawn in its free band. + * SCOPE_CHILDREN contents that DO carry an unselected tag, i.e. the ones + * living inside the child groups. Requested only when there + * are few of them, so the map can show them in place + * instead of making the reader enter a child to find one + * item (the legacy view's `$expandTagGroups` rule). + * SCOPE_ALL the whole selection, for a plain list. + * + * Deciding membership here rather than on the client also fixes two things + * the client could not: the explicit-before-fuzzy ordering biased a + * page-local estimate low, and a tag beyond MAX_CO_TAGS is missing from + * coTags, which made its contents look direct when they are not. + * + * @param array $selectedPaths + * @param array $selectedTags + * @return array{items: array>, total: int, offset: int, limit: int, hasMore: bool} + */ +function contents( + \ContentDatabaseContext $dbContext, + array $selectedPaths, + array $selectedTags, + string $scope, + int $offset, + int $limit +): array { + require_once(MODULE_DIR . "/ContentsViewerUtils.php"); + + $path2tag = $dbContext->metadata->data['path2tag'] ?? []; + + $paths = array_keys($selectedPaths); + if ($scope !== SCOPE_ALL) { + $wantDirect = $scope === SCOPE_DIRECT; + $paths = array_values(array_filter( + $paths, + function (string $path) use ($path2tag, $selectedTags, $wantDirect) { + $isDirect = array_diff_key($path2tag[$path] ?? [], $selectedTags) === []; + return $isDirect === $wantDirect; + } + )); + } + usort($paths, function ($a, $b) use ($selectedPaths) { + $explicitA = is_bool($selectedPaths[$a]); + $explicitB = is_bool($selectedPaths[$b]); + if ($explicitA !== $explicitB) { + return $explicitA ? -1 : 1; + } + if (!$explicitA && $selectedPaths[$a] != $selectedPaths[$b]) { + return ($selectedPaths[$a] < $selectedPaths[$b]) ? 1 : -1; + } + return strcmp($a, $b); + }); + + $total = count($paths); + $page = array_slice($paths, $offset, $limit); + + $items = []; + foreach ($page as $path) { + $item = contentItem($dbContext, $path, $selectedPaths[$path]); + if ($item !== null) { + $items[] = $item; + } + } + + return [ + 'items' => $items, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'hasMore' => $offset + $limit < $total, + ]; +} + +/** + * One content, as the client sees it. + * + * @param $membership true when the content carries a selected tag, or a float + * score when it matched by name similarity instead. Null when the caller has + * no selection to judge against (a key lookup), which reports neither. + */ +function contentItem( + \ContentDatabaseContext $dbContext, + string $path, + $membership, + bool $withSummary = true +): ?array { + require_once(MODULE_DIR . "/ContentsViewerUtils.php"); + + $content = $dbContext->database->get($path); + if (!$content) { + logger()->warning("TagmapQuery: content not found: {$path}"); + return null; + } + $path2tag = $dbContext->metadata->data['path2tag'] ?? []; + $parent = $content->parent(); + // Decoding the body to summarise it costs 8.6 ms per content and nothing + // caches it, so it is skipped unless the caller says it needs it. A + // title is about 1 ms; the difference decides whether naming sixty marks + // takes one second or six. + $summary = $withSummary ? \ContentsViewerUtils\GetDecodedText($content)['summary'] : null; + $suggested = $membership !== null && !is_bool($membership); + return [ + 'title' => \NotBlankText([$content->title, basename($content->path)]), + 'parentTitle' => $parent === false ? null : \NotBlankText([$parent->title, basename($parent->path)]), + 'key' => contentKey($path), + 'url' => \ContentsViewerUtils\CreateContentHREF($content->path), + 'summary' => $summary, + // `suggested` means this content matched by name similarity, not by a + // tag. The UI must mark it: it is not tagged with what the user + // selected. (path2tag mixes authored with machine-inferred tags and + // cannot tell them apart, so `tags` claims no authorship -- it is + // only the membership the index recorded.) + 'suggested' => $suggested, + 'score' => $suggested ? $membership : null, + 'tags' => array_keys($path2tag[$path] ?? []), + ]; +} + +/** + * Maximum keys resolved in one lookup. + * + * Each costs about 17 ms the first time its file is read, so a request is + * bounded by what a viewport can actually show rather than by the manifest. + */ +const MAX_KEYS = 200; + +/** + * The contents behind a set of manifest keys. + * + * The map draws every content of a level from the membership manifest, which + * carries identities and no bodies, so a mark that grows large enough to + * deserve a title has nothing to show. This resolves exactly the marks that + * reached that size -- not a page, which would name whichever contents + * happened to be fetched first and leave the rest anonymous. + * + * Keys are resolved INSIDE the population that minted them ($universe, from + * selectionUniverse()). A manifest key is only meaningful in the selection + * that produced it: resolved against `path2tag` instead, a name match -- in + * the selection but carrying no tag -- was unresolvable, so the map drew a + * mark it could never name (measured: 8 of /Arduino's 65). The population + * also carries each path's membership, which is what lets an item report + * `suggested` truthfully; passing null here reported every content as + * tagged. + * + * A key is 48 bits of an md5, so two paths CAN collide. Both are returned; + * each item carries its own key, so a client that receives two items for one + * key must drop that key rather than pick one. Showing the wrong article's + * title is worse than showing none. This is also why the scan below runs to + * the end instead of stopping once it has one path per requested key: an + * early exit could return the first half of a collision, which a client + * reads as a clean resolution and shows the wrong title. The scan got + * cheaper anyway -- a selection is smaller than the tagged corpus (65 + * against 192 on /Arduino). + * + * @param array $universe path => membership + */ +function contentsByKeys( + \ContentDatabaseContext $dbContext, + array $keys, + array $universe, + bool $withSummary = false +): array { + $wanted = []; + foreach (array_slice(array_values(array_unique($keys)), 0, MAX_KEYS) as $key) { + $wanted[$key] = true; + } + + $items = []; + foreach ($universe as $path => $membership) { + if (!isset($wanted[contentKey($path)])) { + continue; + } + $item = contentItem($dbContext, $path, $membership, $withSummary); + if ($item !== null) { + $items[] = $item; + } + } + return [ + 'items' => $items, + 'requested' => count($wanted), + 'withSummary' => $withSummary, + 'truncated' => count(array_unique($keys)) > MAX_KEYS, + ]; +} + +/** Maximum number of coTag groups included in a response. */ +const MAX_CO_TAGS = 200; + +/** Which contents of the selection a response should list. */ +const SCOPE_DIRECT = 'direct'; // carrying no unselected tag +const SCOPE_CHILDREN = 'children'; // living inside the child groups +const SCOPE_ALL = 'all'; +// Not a population but a lookup: resolve these manifest keys to their bodies. +// It answers a different question, so it returns a different, lean shape. +const SCOPE_KEYS = 'keys'; + +/** @return string one of the SCOPE_* constants */ +function normalizeScope(?string $scope): string +{ + return in_array($scope, [SCOPE_DIRECT, SCOPE_CHILDREN, SCOPE_ALL, SCOPE_KEYS], true) + ? $scope + : SCOPE_DIRECT; +} + +/** + * Assembles the full response payload used both as the API response and as + * the shell's inline initial state. + * Precondition: `$dbContext->LoadMetadata()` has been called and `$parts` + * is canonical and within caps. + * + * @param array $parts + * @return array + */ +function buildResponse( + \ContentDatabaseContext $dbContext, + string $rootDirectory, + string $layerName, + string $layerSuffix, + array $parts, + string $scope, + int $offset, + int $limit +): array { + $started = microtime(true); + + $tag2path = $dbContext->metadata->data['tag2path'] ?? []; + $path2tag = $dbContext->metadata->data['path2tag'] ?? []; + + $canonical = canonicalTagPath($parts); + + $breadcrumb = [['title' => 'TagMap', 'tagPath' => '']]; + $cumulative = []; + foreach ($parts as $segment) { + $cumulative[] = $segment; + $breadcrumb[] = [ + 'title' => implode(', ', $segment), + 'tagPath' => canonicalTagPath($cumulative), + ]; + } + + $selectedTags = []; + foreach ($parts as $part) { + foreach ($part as $tag) { + $selectedTags[$tag] = true; + } + } + + if ($parts === []) { + // The index is loaded even here: `reach` below needs the name matches + // a child group would admit, and at the root nothing else consults it. + $dbContext->LoadIndex(); + $co = coOccurrence([], [], $tag2path, $path2tag); + $coTags = $co['coTags']; + $chipTags = []; + $suggested = []; + $selectedPaths = universeOf([], $path2tag); + } else { + $dbContext->LoadIndex(); + $eachSelected = select($dbContext, $parts); + $co = coOccurrence($eachSelected, $parts, $tag2path, $path2tag); + $coTags = $co['coTags']; + $chipTags = $co['chipTags']; + + $source = count($eachSelected) > 1 ? $eachSelected[count($eachSelected) - 2]['selected'] : null; + $selectedPaths = universeOf($eachSelected, $path2tag); + $suggested = suggestedTags( + $dbContext, + $rootDirectory, + $layerSuffix, + end($parts), + $selectedTags, + $source, + $selectedPaths, + $tag2path, + $path2tag + ); + } + + // `total` is the tag's global content count, independent of the selection. + // Whichever of count/total a client encodes as size, its label must name + // that same number — mixing the two makes circles incomparable. + $totalOf = fn(string $tag): int => count($tag2path[$tag] ?? []); + + // What entering a group actually yields. `count` is a present-tense fact + // about the CURRENT selection and is smaller, because the next view also + // admits name matches: measured on the reference corpus 89% of groups + // agree, but Arduino goes 33 -> 65 and Unity 4 -> 17. A client that sizes + // a group by `count` therefore draws a circle the next view does not fit. + // The source is the current selection, exactly as select() chains it, so + // this is the child view's totalContents rather than an estimate of it. + // Null at the root, NOT the root universe: a null source means "the whole + // corpus", while the root universe is the TAGGED corpus (192 of 217 + // contents). Passing it would restrict findTagSuggestedPaths to tagged + // contents and understate every group's reach. + $reachSource = $parts === [] ? null : $selectedPaths; + $reachOf = function (array $tags) use ($reachSource, $tag2path, $path2tag, $dbContext): int { + return count( + selectTaggedPaths($reachSource, $tags, $tag2path, $path2tag) + + findTagSuggestedPaths($reachSource, $tags, $dbContext->index) + ); + }; + + $totalCoTags = count($coTags); + $coTagList = []; + foreach (array_slice($coTags, 0, MAX_CO_TAGS, true) as $tag => $counts) { + // `tag` is the group's name AND its OR segment; `tags` is what to + // navigate to. `total` is the group's global reach (the union of its + // members), so it stays comparable with a single-tag group. + $coTagList[] = [ + 'tag' => $tag, + 'tags' => $counts['tags'], + 'count' => $counts['count'], + 'orCount' => $counts['orCount'], + 'total' => count(selectTaggedPaths(null, $counts['tags'], $tag2path, $path2tag)), + 'reach' => $reachOf($counts['tags']), + ]; + } + $chipTagList = []; + foreach ($chipTags as $tag => $count) { + $chipTagList[] = ['tag' => $tag, 'count' => $count, 'total' => $totalOf($tag)]; + } + $suggestedList = []; + foreach ($suggested as $tag => $desc) { + $suggestedList[] = [ + 'tag' => $tag, + 'count' => $desc['count'], + 'orCount' => $desc['orCount'], + 'score' => $desc['score'], + 'total' => $totalOf($tag), + ]; + } + $contents = $parts === [] + ? ['items' => [], 'total' => 0, 'offset' => 0, 'limit' => $limit, 'hasMore' => false] + : contents($dbContext, $selectedPaths, $selectedTags, $scope, $offset, $limit); + + // The manifest is memberships only -- no titles, no summaries, no URLs. + // It is what lets the client draw each content once, in the right place, + // at every level including the root (which carries no content list at + // all). Measured at 4 KB for 192 contents; the bodies of the same 192 + // would be two orders of magnitude more. + $manifest = membershipManifest( + $selectedPaths, + $co['pathGroups'], + array_map(fn(array $entry) => $entry['tag'], $coTagList) + ); + + return [ + 'tagPath' => $canonical === '' ? '' : '/' . $canonical, + 'layer' => $layerName, + 'segments' => $parts, + 'scope' => $scope, + 'breadcrumb' => $breadcrumb, + 'coTags' => $coTagList, + 'totalCoTags' => $totalCoTags, + 'chipTags' => $chipTagList, + 'suggestedTags' => $suggestedList, + 'memberships' => $manifest['rows'], + 'manifestTruncated' => $manifest['truncated'], + 'contents' => $contents, + 'stats' => [ + // Every number a client can honestly print about this selection. + // At the root there is no selection, so the size of the tagged + // corpus is the only meaningful total (it used to report 0). + // The root's universe is a real population now (every tagged + // content, membership `true`), so both figures read it directly + // instead of special-casing the root. + 'totalContents' => count($selectedPaths), + 'explicitContents' => count(array_filter($selectedPaths, 'is_bool')), + 'directContents' => $co['directCount'], + // Distinct contents living inside the child groups. A client uses + // it to decide whether to show them in place instead of making + // the reader enter a child to find one item; it is NOT the sum of + // the groups' counts, which double-counts anything carrying two + // of them. + 'childContents' => $parts === [] + ? 0 + : count($selectedPaths) - $co['directCount'], + 'orBase' => $co['orBase'], + 'generatedInMs' => round((microtime(true) - $started) * 1000, 1), + ], + ]; +} + +/** + * Response schema version. Bump whenever the response shape OR the meaning + * of any value in it changes: it is part of the cache key, so old entries + * become unreachable (and age out via TTL) instead of being served to a + * client that expects the new fields. A meaning-only change matters just as + * much — without a bump, a deploy keeps serving numbers computed the old way + * for up to the full TTL. + */ +const RESPONSE_SCHEMA = 10; + +function cacheKey( + string $rootDirectory, + string $layerSuffix, + string $canonicalTagPath, + string $scope, + int $offset, + int $limit +): string { + // The tag-path part is hashed: percent-encoded multibyte tags would blow + // up the cache file name length otherwise. Root and layer stay readable + // for debugging; RESPONSE_SCHEMA isolates older response shapes. + return 'tagmap-api' . RESPONSE_SCHEMA . '-' . $rootDirectory . $layerSuffix . '-' + . sha1($canonicalTagPath . '|' . $scope . '|' . $offset . '|' . $limit); +} + +/** + * Cached JSON response, shared by the shell and the API service (same key: + * either one warms the cache for the other). Valid while nothing under the + * root has changed (`updatedTime > contentsChangedTime`), at most one day. + * + * NOTE: contentsChangedTime only advances during a crawl, and this path just + * loads metadata. Editing a content nobody opens therefore stays invisible + * here until the entry expires. The one-day TTL is the real bound. + * + * Precondition: `$dbContext->LoadMetadata()` has been called. + * + * @param array $parts canonical, within caps + */ +function responseJson( + \ContentDatabaseContext $dbContext, + string $rootDirectory, + string $layerName, + string $layerSuffix, + array $parts, + string $scope, + int $offset, + int $limit +): string { + // A missing contentsChangedTime means "we cannot tell whether contents + // changed", which must not be read as "nothing changed" -- that served a + // day-old response unconditionally. Without the key, skip the cache read. + $changeTimeKnown = array_key_exists('contentsChangedTime', $dbContext->metadata->data); + $contentsChangedTime = $changeTimeKnown ? $dbContext->metadata->data['contentsChangedTime'] : 0; + $key = cacheKey($rootDirectory, $layerSuffix, canonicalTagPath($parts), $scope, $offset, $limit); + + $cache = new \Cache(); + // Probe before connecting: connect() opens with 'c+b' and touches, so an + // unconditional read would create and stamp a file just to miss on it. + if ($changeTimeKnown && \CacheStore::exists($key)) { + $cache->connect($key); + $cache->lock(LOCK_SH); + $cache->fetch(); + $cache->unlock(); + $cache->disconnect(); + // Strictly newer: with >=, a content modified in the same second as + // the write would satisfy the condition and stay hidden for a day. + if ( + isset($cache->data['json'], $cache->data['updatedTime']) + && $cache->data['updatedTime'] > $contentsChangedTime + ) { + return $cache->data['json']; + } + } + + $response = buildResponse( + $dbContext, + $rootDirectory, + $layerName, + $layerSuffix, + $parts, + $scope, + $offset, + $limit + ); + $json = json_encode($response, JSON_UNESCAPED_UNICODE); + + $cache->connect($key); + $cache->lock(LOCK_EX); + $cache->fetch(); + $cache->data = ['json' => $json, 'updatedTime' => time(), 'expires' => 86400]; + $cache->apply(); + $cache->unlock(); + $cache->disconnect(); + + return $json; +} + +// --- Selection primitives (moved unchanged from Frontend/tag-viewer.php) --- + +/** + * ['pathA' => any, 'pathB' => any, ...] + * + * @param array|null $source + * ['pathA' => any, 'pathB' => any, ...] + * @param string[] $selectorTags + * @param array $tag2path + * @param array $path2tag + * @return array + */ +function selectTaggedPaths($source, $selectorTags, $tag2path, $path2tag) +{ + $selectedPaths = []; + foreach ($selectorTags as $tag) { + if (isset($tag2path[$tag])) { + $selectedPaths += $tag2path[$tag]; + } + } + + if (is_null($source)) { + return $selectedPaths; + } + + return array_intersect_key($source, $selectedPaths); +} + +/** + * @param array|null $source + * @param string[] $selectorTags + * @param \SearchEngine\Index $index + * @return array + */ +function findTagSuggestedPaths($source, $selectorTags, $index) +{ + $suggestions = []; + foreach ($selectorTags as $tag) { + $suggestions = array_merge( + $suggestions, + $index->search($tag) + ); + } + + foreach ($suggestions as $i => $suggested) { + if ($suggested['score'] < 0.75) { + unset($suggestions[$i]); + } + } + + sortSuggestions($suggestions); + + $selectedPaths = []; + foreach ($suggestions as $suggested) { + $selectedPaths[$suggested['id']] = $suggested['score']; + } + + if (is_null($source)) { + return $selectedPaths; + } + return array_intersect_key($selectedPaths, $source); +} + +function sortSuggestions(&$suggestions) +{ + uasort($suggestions, function ($a, $b) { + if ($a['score'] == $b['score']) { + return 0; + } + return ($a['score'] < $b['score']) ? 1 : -1; + }); +} diff --git a/Service/access-gate-seed-service.php b/Service/access-gate-seed-service.php new file mode 100644 index 0000000..3af6108 --- /dev/null +++ b/Service/access-gate-seed-service.php @@ -0,0 +1,27 @@ + $seed, 'bits' => AccessGate::powBits()]); diff --git a/Service/feedback-service.php b/Service/feedback-service.php index dcc277c..990a8c1 100644 --- a/Service/feedback-service.php +++ b/Service/feedback-service.php @@ -7,11 +7,26 @@ require_once dirname(__FILE__) . '/../Module/ServiceUtils.php'; require_once dirname(__FILE__) . '/../Module/CacheStore.php'; require_once dirname(__FILE__) . "/../Module/Authenticator.php"; +require_once dirname(__FILE__) . "/../Module/AccessGate.php"; require_once dirname(__FILE__) . "/../Module/ContentsViewerUtils.php"; require_once dirname(__FILE__) . "/../Module/ContentDatabase.php"; set_error_handler('ErrorHandling\PlainErrorHandler'); +/** + * Anonymous writes require an AccessGate token (replaces the former OTP): + * acquisition costs a proof-of-work per client IP, and SameSite=Lax on the + * cookie doubles as CSRF protection for cross-site POSTs. + * No-op (fail-open) when the gate is not configured. + */ +function requireAccessGateToken(): void +{ + if (!AccessGate::verifyToken($_COOKIE[AccessGate::COOKIE_NAME] ?? '', $_SERVER['REMOTE_ADDR'] ?? '')) { + http_response_code(428); + ServiceUtils\SendErrorResponseAndExit('challenge_required'); + } +} + use ContentsViewerUtils as CVUtils; ServiceUtils\RequirePostMethod(); @@ -20,12 +35,10 @@ $feedbackCacheName = 'feedback-'; if($cmd == 'rate') { - ServiceUtils\RequireParams('otp', 'contentPath', 'rating'); + ServiceUtils\RequireParams('contentPath', 'rating'); $rating = $_POST['rating']; $contentPath = $_POST['contentPath']; - if(!authenticator()->verifyOtp($_POST['otp'])) { - ServiceUtils\SendErrorResponseAndExit('Invalid access.'); - } + requireAccessGateToken(); ServiceUtils\ValidateAccessPrivilege($contentPath, false, $owner); $contentFilePath = ContentPathUtils::RealPath($contentPath . Content::EXTENSION); if($contentFilePath === false) { @@ -80,12 +93,10 @@ } else if($cmd == 'message') { - ServiceUtils\RequireParams('otp', 'contentPath', 'message'); + ServiceUtils\RequireParams('contentPath', 'message'); $contentPath = $_POST['contentPath']; $message = $_POST['message']; - if(!authenticator()->verifyOtp($_POST['otp'])) { - ServiceUtils\SendErrorResponseAndExit('Invalid access.'); - } + requireAccessGateToken(); ServiceUtils\ValidateAccessPrivilege($contentPath, false, $owner); $contentFilePath = ContentPathUtils::RealPath($contentPath . Content::EXTENSION); if($contentFilePath === false) { diff --git a/Service/mail-service.php b/Service/mail-service.php index fb2ba09..edc09b9 100644 --- a/Service/mail-service.php +++ b/Service/mail-service.php @@ -7,14 +7,19 @@ require_once dirname(__FILE__) . '/../Module/ServiceUtils.php'; require_once dirname(__FILE__) . '/../Module/Utils.php'; require_once dirname(__FILE__) . "/../Module/Authenticator.php"; +require_once dirname(__FILE__) . "/../Module/AccessGate.php"; set_error_handler('ErrorHandling\PlainErrorHandler'); ServiceUtils\RequirePostMethod(); -ServiceUtils\RequireParams('contentPath', 'subject', 'name', 'email', 'message', 'returnTo', 'otp'); +ServiceUtils\RequireParams('contentPath', 'subject', 'name', 'email', 'message', 'returnTo'); -if(!authenticator()->verifyOtp($_POST['otp'])) { - ServiceUtils\SendErrorResponseAndExit('Invalid access.'); +// Anonymous writes require an AccessGate token (replaces the former OTP); +// SameSite=Lax on the cookie doubles as CSRF protection. +// No-op (fail-open) when the gate is not configured. +if(!AccessGate::verifyToken($_COOKIE[AccessGate::COOKIE_NAME] ?? '', $_SERVER['REMOTE_ADDR'] ?? '')) { + http_response_code(428); + ServiceUtils\SendErrorResponseAndExit('challenge_required'); } ServiceUtils\ValidateAccessPrivilege($_POST['contentPath'], false, $owner); if(!authenticator()->getUserInfo($owner, 'notifyingList', $notifyingList)) { diff --git a/Service/tagmap-service.php b/Service/tagmap-service.php new file mode 100644 index 0000000..78ce758 --- /dev/null +++ b/Service/tagmap-service.php @@ -0,0 +1,196 @@ += count, and on the reference corpus +// they agree for 89% of groups but differ by up to 4x. +// Size a group by reach, not by count: a circle drawn from +// count is not the size of the view behind it. +// orCount = size after OR-adding the group to the last +// segment. total = the group's global reach (the union of +// its tags), selection-independent. +// NOTE: the counts do NOT sum to the selection size. A +// content carrying two groups is counted in both; use +// stats.childContents for the distinct figure. +// suggestedTags: [{tag, count, orCount, total, score}] — similar tag names. +// count may legitimately be 0 (a similar NAME sharing no +// content); do not substitute another field for it. +// chipTags: [{tag, count, total}] — last segment only, parent-based. +// contents: one page; `scope` echoes which population it is drawn +// from. items[]: {title, parentTitle, url, summary, +// suggested, score, tags}. `suggested` = matched by name +// similarity rather than by a tag, and must be marked. +// stats: totalContents (name matches included), explicitContents +// (tagged only — the difference explains why a drill-down +// can grow), directContents (under the selection itself), +// childContents (distinct contents inside the child groups; +// a client shows them in place when this is small instead of +// making the reader enter a group to find one item), orBase +// (the name-blind size orCount is measured from, so +// orCount - orBase is a non-negative delta; null at the +// root), generatedInMs. +// +// No coordinates: layout is a presentation concern and never leaves the +// client. A client must label every number with the field it came from and +// must not fall back to a different field when one is missing. + +require_once(dirname(__FILE__) . "/../ContentsPlanet.php"); +require_once(MODULE_DIR . '/Logger.php'); +require_once(MODULE_DIR . '/ServiceUtils.php'); +require_once(MODULE_DIR . '/ErrorHandling.php'); +require_once(MODULE_DIR . '/AccessGate.php'); +require_once(MODULE_DIR . '/PathUtils.php'); + +set_error_handler('ErrorHandling\PlainErrorHandler'); + +ServiceUtils\RequirePostMethod(); +ServiceUtils\RequireParams('contentPath', 'tagPath'); + +// Gate first, before any filesystem or database work: this endpoint serves +// the tag-map SPA and is a scraping target. Fail-open when unconfigured. +if (!AccessGate::verifyToken($_COOKIE[AccessGate::COOKIE_NAME] ?? '', $_SERVER['REMOTE_ADDR'] ?? '')) { + http_response_code(428); + header('Content-Type: application/json; charset=UTF-8'); + echo json_encode(['error' => 'challenge_required']); + exit; +} + +header('Content-Type: application/json; charset=UTF-8'); + +try { + $contentPath = PathUtils\canonicalize($_POST['contentPath']); +} catch (Exception $error) { + ServiceUtils\SendErrorResponseAndExit('Invalid Parameter'); +} +// Restore the conventional './'-relative form: canonicalize strips it, but +// GetTopDirectory and the content database expect it. +if (!str_starts_with($contentPath, './')) { + $contentPath = './' . ltrim($contentPath, '/'); +} + +ServiceUtils\ValidateAccessPrivilege($contentPath); + +require_once(MODULE_DIR . '/ContentDatabase.php'); +require_once(MODULE_DIR . '/ContentDatabaseContext.php'); +require_once(MODULE_DIR . '/ContentDatabaseControls.php'); +require_once(MODULE_DIR . '/TagmapQuery.php'); + +use ContentDatabaseControls as DBControls; + +$dbContext = new ContentDatabaseContext($contentPath); +if (ContentPathUtils::RealPath($dbContext->metaFileName) === false) { + ServiceUtils\SendErrorResponseAndExit('Invalid Parameter'); +} + +$rootDirectory = substr(GetTopDirectory($contentPath), 1); +$layerName = DBControls\GetRelatedLayerName($contentPath); +if ($layerName === false) { + $layerName = DEFAULT_LAYER_NAME; +} +$layerSuffix = DBControls\GetLayerSuffix($layerName); + +$offset = max(0, (int)($_POST['offset'] ?? 0)); +$limit = min(20, max(1, (int)($_POST['limit'] ?? 20))); +// Which contents to list. Defaults to the map's own population. +$scope = TagmapQuery\normalizeScope($_POST['scope'] ?? null); + +$dbContext->LoadMetadata(); +$tag2path = $dbContext->metadata->data['tag2path'] ?? []; + +$tagPath = $_POST['tagPath']; +$parsed = TagmapQuery\parseTagPath($tagPath); + +// scope=keys is a lookup, not a view: it resolves manifest keys to bodies for +// marks the client is already drawing, so it needs no co-occurrence and no +// cache entry -- and it must not be given the shape of a view, or a client +// could mistake it for one. It DOES need the selection, because that is what +// gives a manifest key a referent. +// +// It deliberately skips canonicalisation and the caps: a lookup must not +// answer with a redirect (the client is waiting for bodies, not for a new +// URL), and the client only ever sends back the selection this service +// already canonicalised for it. A tagPath that resolves to nothing simply +// resolves no keys, which is the same answer as an empty selection. +if ($scope === TagmapQuery\SCOPE_KEYS) { + $keys = preg_split('/[,\s]+/', (string)($_POST['keys'] ?? ''), -1, PREG_SPLIT_NO_EMPTY); + // Summaries are opt-in: decoding a body to summarise it is the expensive + // part (~3-4 ms each, uncached). The client asks for them on every + // lookup, because the alternative -- titles now, summaries later -- reads + // the same file twice to save one decode. + $withSummary = ($_POST['fields'] ?? '') === 'full'; + ServiceUtils\SendResponseAndExit( + // The REQUEST's tagPath, echoed verbatim -- deliberately not the + // canonical form a view reports as `tagPath`. The client compares it + // against the string it sent, so the guard is a pure round-trip + // identity check: it can only fail because the selection actually + // changed, never because two normalisers disagreed about collation. + ['scope' => TagmapQuery\SCOPE_KEYS, 'requestedTagPath' => $tagPath] + + TagmapQuery\contentsByKeys( + $dbContext, + $keys ?: [], + TagmapQuery\selectionUniverse($dbContext, $parsed), + $withSummary + ) + ); +} + +$canonicalized = TagmapQuery\canonicalize($parsed, $tag2path, $tagPath); +if ($canonicalized['changed']) { + ServiceUtils\SendResponseAndExit([ + 'error' => 'canonical_mismatch', + 'canonical' => $canonicalized['canonical'] === '' ? '' : '/' . $canonicalized['canonical'], + ]); +} +if (!TagmapQuery\withinCaps($canonicalized['parts'])) { + ServiceUtils\SendResponseAndExit([ + 'error' => 'limit_exceeded', + 'max' => ['depth' => TagmapQuery\maxDepth(), 'width' => TagmapQuery\maxWidth()], + ]); +} + +echo TagmapQuery\responseJson( + $dbContext, + $rootDirectory, + $layerName, + $layerSuffix, + $canonicalized['parts'], + $scope, + $offset, + $limit +); diff --git a/index.php b/index.php index ae542c8..948fb33 100644 --- a/index.php +++ b/index.php @@ -2,6 +2,17 @@ require_once(dirname(__FILE__) . '/ContentsPlanet.php'); +// Client-attestation gate. Runs before any other module loads or any output: +// requests to protected URIs without a valid token receive the challenge +// page here and never reach the rest of the bootstrap. +// Disabled (fail-open) unless ACCESS_GATE_SECRET is configured. +$accessGateEnabled = defined('ACCESS_GATE_SECRET') && ACCESS_GATE_SECRET !== '' + && defined('ACCESS_GATE_PROTECTED_URIS') && ACCESS_GATE_PROTECTED_URIS !== []; +if ($accessGateEnabled) { + require_once(MODULE_DIR . '/AccessGate.php'); + AccessGate::handle(); +} + require_once(MODULE_DIR . '/Logger.php'); require_once(MODULE_DIR . '/Utils.php'); require_once(MODULE_DIR . '/ContentDatabase.php'); @@ -13,6 +24,38 @@ set_error_handler('ErrorHandling\StyledErrorHandler'); // --- Setup htaccess file --- + +// Tagmap URL-space caps: over-deep/over-wide tag paths get a bare Apache 404 +// before PHP starts. This is a tagmap domain rule, independent of the access +// gate; the same caps are enforced authoritatively in PHP. +$htaccessTagmapCapsDesc = ''; +if (defined('TAGMAP_MAX_DEPTH') && defined('TAGMAP_MAX_WIDTH')) { + $htaccessTagmapCapsDesc = + "\nRewriteCond %{REQUEST_URI} \":tagmap(/[^/]*){" . (TAGMAP_MAX_DEPTH + 1) . "}\" [NC,OR]\n" . + "RewriteCond %{REQUEST_URI} \":tagmap/.*([^/,]*,){" . TAGMAP_MAX_WIDTH . "}\" [NC]\n" . + "RewriteRule ^ - [R=404,L]\n"; +} + +// Access gate prefilter: cookie-less requests to protected URIs get the +// static challenge page (existence check only; AccessGate::handle() above +// validates the token for requests that do carry a cookie). +$htaccessGateDesc = ''; +$htaccessGateHeadersDesc = ''; +if ($accessGateEnabled) { + foreach (AccessGate::protectedUris() as $protectedUri) { + $htaccessGateDesc .= + "\nRewriteCond %{REQUEST_URI} \"" . preg_quote($protectedUri) . "\" [NC]\n" . + "RewriteCond %{HTTP_COOKIE} !" . AccessGate::COOKIE_NAME . "=\n" . + "RewriteRule .* " . AccessGate::CHALLENGE_PATH . " [L]\n"; + } + $htaccessGateHeadersDesc = + "\n\n" . + "\n" . + "Header set Cache-Control \"no-store\"\n" . + "\n" . + "\n"; +} + $htaccessDesc = "\n\n" . "RewriteEngine On\n" . @@ -20,12 +63,15 @@ "\nRewriteCond %{HTTPS} off\n" . "RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]\n" : '') . + $htaccessTagmapCapsDesc . + $htaccessGateDesc . "\nRewriteCond %{REQUEST_URI} !(^" . CLIENT_URI . "/)\n" . "RewriteCond %{REQUEST_URI} !(^" . SERVICE_URI . "/)\n" . "RewriteRule ^(.*)$ index.php\n" . "\nRewriteCond %{HTTP:Authorization} ^(.*)\n" . "RewriteRule ^(.*) - [E=HTTP_AUTHORIZATION:%1]\n" . - "\n"; + "\n" . + $htaccessGateHeadersDesc; // NOTE: fopen オプション w ではなく c にする理由 // wの時は, ファイルポインタをファイルの先頭に置き, ファイルサイズをゼロにします.