diff --git a/assets/js/branches.js b/assets/js/branches.js new file mode 100644 index 00000000..5d2d4b17 --- /dev/null +++ b/assets/js/branches.js @@ -0,0 +1,239 @@ +import { line, curveNatural, curveBumpY } from "d3-shape"; +import { select, selectAll } from "d3-selection"; +import { randomNumBetween } from "./leaves"; +import { BaseSVG } from "./utils"; + +// combine into d3 object for convenience +const d3 = { + line, + curveNatural, + curveBumpY, + select, + selectAll, +}; + +function drawTreeSegment(points) { + const curve = d3.line().curve(d3.curveBumpY); + return curve(points.map((d) => [d.x, d.y])); // [start, end]); +} + +// how to do this? needs context/scale +// trunk width should be a constant, +// top/bottom coords should be fixed +// - needed for both tree and roots + +const trunkWidth = 150; // 110; +const trunkBaseWidth = trunkWidth * 0.9; + +const trunk = { + width: trunkWidth, + topLeft: -trunkWidth / 2, + topRight: trunkWidth / 2, + bottomLeft: -trunkBaseWidth / 2, + bottomRight: trunkBaseWidth / 2, +}; + +function drawTrunk(container, [min_x, min_y, width, height], trunkTop) { + // draw lines for the trunk, + // to make it easier to read as a tree + + const max_y = min_y + height; + + // extend trunk off the bottom edge of the svg, + // so that trunk and roots stay connected when resizing + const trunkExtra = 600; + + // add points for unevenness at the thirds of the trunk + let onethird = (trunkTop - trunk.bottomLeft) / 4; + + // generate points for left side + const leftSidePoints = [ + [trunk.topLeft, trunkTop], + [trunk.topLeft * 0.9, trunkTop + onethird], + [trunk.topLeft * 0.8, trunkTop + onethird * 2], + [trunk.bottomLeft * 0.9, trunkTop + onethird * 3], + [trunk.bottomLeft, max_y], + [trunk.bottomLeft, max_y + trunkExtra], + ].map((d) => { + return { x: d[0], y: d[1] }; + }); + + // draw the path for the left side + container + .append("path") + .attr("class", "trunk") + .attr("d", drawTreeSegment(leftSidePoints)); + + // generate points for right side + + const rightSidePoints = [ + [trunk.topRight, trunkTop], + [trunk.topRight * 0.9, trunkTop + onethird], + [trunk.topRight * 0.95, trunkTop + onethird * 2], + [trunk.bottomRight * 0.9, trunkTop + onethird * 3], + [trunk.bottomRight, max_y], + [trunk.bottomRight, max_y + trunkExtra], // extend off the edge of the svg, for resizing + ].map((d) => { + return { x: d[0], y: d[1] }; + }); + + // draw the path for the right side + container + .append("path") + .attr("class", "trunk") + .attr("d", drawTreeSegment(rightSidePoints)); +} + +function drawBranches(nodes, container, branches, trunkTop) { + // draw branches + + let branchNodes = nodes.filter((d) => d.type == "branch"); + // calculate starting coordinates for each branch + // tree width is defined as a constant; + // assuming center of svg is 0,0 + // top of tree is passed in from timetree code + let leftBranchX = -trunkWidth / 2; + // second branch starts up 1/3 of the trunk width + let secondBranchY = trunkWidth * 0.3; + // third and fourth stair step down in thirds + let steps = (trunkTop - secondBranchY) * 0.3; + + // calculate starting coordinates for each of the five branches + let branchStart = [ + // left-most branch + { x: trunk.topLeft, y: trunkTop }, + // [leftBranchX, trunkTop], // left-most branch + // second branch starts over 6% of tree width + { x: trunk.topLeft + trunkWidth * 0.06, y: trunkTop - secondBranchY }, + // third is 48% of width + { + x: trunk.topLeft + trunkWidth * 0.48, + y: trunkTop - secondBranchY + steps, + }, + // fourth is 70% of width + { + x: trunk.topLeft + trunkWidth * 0.7, + y: trunkTop - secondBranchY + steps + steps, + }, + // right-most branch + { x: trunk.topRight, y: trunkTop }, + ]; + + // insert branches before node group layer, + // so it will render as underneath the leaves + let branchPaths = container + .insert("g", ".nodes") + .attr("class", "branches") + .selectAll("path") + .data(Object.keys(branches)) // join to branch names passed in + .join("path") + // draw branch path for leaves, empty path for everything else + .attr("class", "branch") + .attr("d", (b, i) => { + // start at the calculated branch point for this branch, + // then use branch pseudo nodes as coordinates + let branchPoints = [ + branchStart[i], + ...branchNodes.filter((d) => d.branch == b), + ]; + return drawTreeSegment(branchPoints); + }); +} + +class Roots extends BaseSVG { + constructor() { + super(); + + // configure so point [0, 0] is the center top of the svg + + // use same logic as for the timetree svg width + let width = this.getSVGWidth(); // width depends on if mobile or not + let height = 130; + let min_x = -width / 2; + let min_y = 0; + + // TODO: use a graphic for mobile, + // since it is decorative and not functional ? + + let center_x = min_x + width / 2; + + const svg = d3 + .select("body > footer") + .append("svg") + .lower() + .attr("id", "roots") + .attr("viewBox", [min_x, min_y, width, height]); + + // for debugging: mark the center of the svg + // svg + // .append("circle") + // .attr("r", 5) + // .attr("fill", "red") + // .attr("cx", min_x + width / 2) + // // .attr("cx", width / 2) + // // .attr("cx", 0) + // .attr("cy", min_y + height / 2); + + const navLinks = document.querySelectorAll("body > footer > nav > a"); + let linkCount = navLinks.length; + // divide into equal sections based on the number of nav links + let sectionwidth = width / linkCount + 1; + + let center_y = height / 2; + + let currentURL = window.location.pathname; + + // draw one root for each footer nav link + navLinks.forEach((a, i) => { + // determine if left or right, based half point of leaves + let left = i < linkCount / 2; + + let startx = left ? trunk.bottomLeft : trunk.bottomRight; + let targetX = min_x + sectionwidth * i + sectionwidth / 2; + + // create a branch off point for secondary root line + let secondaryRootStart = [ + // start part way to the target x coord + ((targetX - startx) / 3) * 2 + (left ? -45 : 45), + // and somewhere between a third and a half of the svg height + randomNumBetween(height / 3, height / 2), + ]; + + let rootCoords = [ + [center_x + startx, min_y], + [center_x + startx + (left ? -8 : 8), min_y + 7], + secondaryRootStart, + [targetX, center_y], + [targetX + (left ? -25 : 25), height], + ].map((d) => { + return { x: d[0], y: d[1] }; + }); + + let path = drawTreeSegment(rootCoords); + let current = a.getAttribute("aria-current") == "page"; + // set root as current if nav link page is for the current page + svg + .append("path") + .attr("class", `root ${current ? "current" : ""}`) + .attr("d", path); + + let secondaryRootCoords = [ + rootCoords[2], // = secondary root start + { + x: secondaryRootStart[0] + (left ? -43 : 43), + y: secondaryRootStart[1] + 52, + }, + { x: secondaryRootStart[0] + (left ? -55 : 55), y: height }, + ]; + + svg + .append("path") + .attr("class", "root") + .attr("d", drawTreeSegment(secondaryRootCoords)); + }); + + // NOTE: html coords != svg coords, so bounding rects doesn't help + } +} + +export { drawTreeSegment, Roots, drawTrunk, drawBranches }; diff --git a/assets/js/keys.js b/assets/js/keys.js new file mode 100644 index 00000000..42fbeef8 --- /dev/null +++ b/assets/js/keys.js @@ -0,0 +1,51 @@ +/* mixin for keypress management */ + +import { select, selectAll } from "d3-selection"; +// combine into d3 object for convenience +const d3 = { + select, + selectAll, +}; + +// mixin extends syntax from +// https://blog.bitsrc.io/inheritance-abstract-classes-and-class-mixin-in-javascript-c636ac00f5a9 + +const TimeTreeKeysMixin = (Base) => + class extends Base { + bindKeypressHandler() { + // make panel object available in event handler context + let panel = this.panel; + + document.onkeydown = function (evt) { + // Get event object + evt = evt || window.event; + + // Keypress switch logic + switch (evt.key) { + // Escape key closes the panel + case "Escape": + case "Esc": + panel.close(); + break; + + // Enter or space key activates focused element with button role + case "Enter": + case " ": + // if target element has role=button (i.e. leaves in the tree), + // trigger click behavior + if (evt.target.getAttribute("role", "button")) { + d3.select(evt.target).dispatch("click"); + } + break; + + // ... Add other cases here for more keyboard commands ... + + // Otherwise + default: + return; // Do nothing + } + }; + } + }; + +export { TimeTreeKeysMixin }; diff --git a/assets/js/labels.js b/assets/js/labels.js new file mode 100644 index 00000000..5244380f --- /dev/null +++ b/assets/js/labels.js @@ -0,0 +1,45 @@ +// const labelLineHeight = 18; +// multiplier to use for calculating size based on characters +// const pixelsPerChar = 7; + +class LeafLabel { + static lineHeight = 18; + // multiplier to use for calculating size based on characters + static pixelsPerChar = 7; + + constructor(label = null) { + this.text = label; + // always need parts and want to calculate once, so getter doesn't make sense + this.parts = this.splitLabel(); + } + + splitLabel() { + // split a leaf label into words for wrapping + // for now, splitting on whitespace, but could adjust + if (this.text == null || this.text == undefined) { + return ["no title"]; + } + return this.text.split(" "); + } + + get height() { + // height is based on line height and number of words + return this.parts.length * LeafLabel.lineHeight; + } + + get width() { + // width is based on the longest word + return ( + Math.max(...this.parts.map((w) => w.length)) * LeafLabel.pixelsPerChar + ); + } + + get radius() { + // calculate radius based on text content, for avoiding collision in + // the d3-force simulation + // determine whichever is bigger is the diameter; halve for radius + return Math.max(this.width, this.height) / 2.0; + } +} + +export { LeafLabel }; diff --git a/assets/js/leaves.js b/assets/js/leaves.js new file mode 100644 index 00000000..f92da2b5 --- /dev/null +++ b/assets/js/leaves.js @@ -0,0 +1,566 @@ +// logic to generate a path for drawing leaves, +// and for managing leaf details and tag behavior in the timetree + +import { select, selectAll } from "d3-selection"; +import { line, curveNatural } from "d3-shape"; + +// combine into d3 object for convenience +const d3 = { + line, + curveNatural, + select, + selectAll, +}; + +// configuration for leaf sizes +// sizes are scaled from 40px width design: +// min height and width: 40px; max height 100px; +// max mid shift 34 (34px at most, 17 either way); max tip shift 12 + +let leafSize = { + minHeight: 30, + maxHeight: 75, + width: 30, + maxMidShift: 25, + maxTipShift: 9, +}; + +// get a random number between a min and max +function randomNumBetween(max, min = 0) { + // scale random value between 0 and 1 to desired scale, start at min value + return Math.random() * (max - min) + min; +} + +function plusOrMinus(x) { + return randomNumBetween(x, -x); +} + +function cointoss() { + // randomly return true or false; + // based on https://stackoverflow.com/a/60322877/9706217 + return Math.random() < 0.5; +} + +const TagSelectEvent = new Event("tag-select"); +const TagDeselectEvent = new Event("tag-deselect"); + +class Leaf { + // constant for selection classname + static selectedClass = "select"; + static highlightClass = "highlight"; + + constructor(panel, ignore_ids, tags) { + // store a reference to the panel object + this.panel = panel; + this.container = document.querySelector("aside"); + this.bindHandlers(); + // list of ids to ignore when loading leaf details + // (known non-leaf elements, including branch start targets) + this.ignore_ids = ignore_ids || []; + this.tags = tags || []; + } + + static isTag(element) { + // check if an element is a tag + return ( + element.tagName == "A" && element.parentElement.classList.contains("tags") + ); + } + + bindHandlers() { + // bind a delegated click handler to override tag link behavior; + // delegated so it applies to tags in leaf details loaded after bound + this.container.addEventListener("click", (event) => { + let element = event.target; + // if click target is a link in the tags section, select leaves for that tag + if (Leaf.isTag(element)) { + event.preventDefault(); + event.stopPropagation(); + this.currentTag = element.dataset.tag; + element.classList.add(Leaf.selectedClass); + this.container.dispatchEvent(TagSelectEvent); + } + }); + + // bind handler to current tag x button to deactivate tag + this.activeTagClose = document.querySelector("#current-tag .close"); + if (this.activeTagClose) { + this.activeTagClose.addEventListener("click", (event) => { + this.currentTag = null; + this.container.dispatchEvent(TagDeselectEvent); + }); + } + + // focus management for the full page / timetree and within the panel + let body = document.querySelector("body"); + body.addEventListener("focusout", this.handleFocusOut.bind(this)); + + // listen for hash change; update selected leaf on change + window.addEventListener("hashchange", this.updateSelection.bind(this)); + + // deselect current leaf when the panel is closed + if (this.panel && this.panel.el) { + // should only be undefined in tests + this.panel.el.addEventListener( + "panel-close", + this.handleClosePanel.bind(this) + ); + } + } + + handleFocusOut(event) { + if (!event.relatedTarget) { + // if event doesn't have a next target for focus, + // then we don't need to handle it + return true; + } + if (event.relatedTarget.id == "panel") { + // when code transfers focus to the panel, do nothing + return true; + } + + const body = document.querySelector("body"); + + // boolean indicating whether a tag is active + const tagActive = body.classList.contains("tag-active"); + // boolean indicating whether leaf details are visible + const leafVisible = this.panel.detailsVisible; + + // when a leaf is visible and focus out event is inside panel, + // keep focus contained within the panel (act like a modal) + if (leafVisible && this.container.contains(event.target)) { + // handle any tab that would move focus out of the container + const closeButton = this.container.querySelector("button.close"); + if ( + event.relatedTarget && + !this.container.contains(event.relatedTarget) + ) { + // if the user is tabbing out of the container and + // the close button is the element losing focus, + // then this is a shift+tab; shift focus to the last tag + if (event.target == closeButton) { + this.container.querySelector(".tags a:last-child").focus(); + } else { + // otherwise, user has tabbed through the last tag; + // shift focus to the close button + closeButton.focus(); + } + } else if (event.relatedTarget == this.activeTagClose) { + // when a tag is active, the tag close button is focusable + // and inside the panel; skip it + + // if focus just left the close button, + // move focus to beginning of the panel + if (event.target == closeButton) { + this.container.querySelector("#panel").focus(); + } else { + // otherwise, move focus to the close button + closeButton.focus(); + } + } + } else if (tagActive) { + // when a tag is active, treat the tree like a modal + // and contain tabs within the tree + active tag close button + + const timetree = document.getElementById("timetree"); + if (event.target == this.activeTagClose) { + const highlightedLeaves = timetree.querySelectorAll( + "path.leaf.highlight" + ); + const firstLeaf = highlightedLeaves[0]; + const lastLeaf = highlightedLeaves[highlightedLeaves.length - 1]; + // if the next target is inside the container, then it was a + // tab forward and we tabbed into the intro; skip to first highlighted leaf + if (this.container.contains(event.relatedTarget)) { + firstLeaf.focus(); + } else { + // otherwise, shift+tab; focus on the last highlighted leaf + lastLeaf.focus(); + } + } else if (!timetree.contains(event.relatedTarget)) { + // if not tabbing from active tag close button and + // next tab would move out of timetree container, + // shift focus to active tag close button + this.activeTagClose.focus(); + } + } + } + + handleClosePanel(event) { + // if a leaf is selected, transfer focus back to leaf or dedication before closing + if (this.currentLeaf != undefined) { + document + .querySelector(`[tabindex][data-id="${this.currentLeaf}"]`) + .focus(); + } + // then clear current leaf + this.currentLeaf = event; + } + + set currentLeaf(event) { + // Are we deselecting a leaf? + // deselect if called with no argument or on panel-close event + if (event == undefined || event == null || event.type == "panel-close") { + // remove hash + let urlNoHash = window.location.pathname + window.location.search; + history.replaceState(null, "", urlNoHash); + } else { + // get the actual leaf target from DOM + let target = Leaf.getLeafTarget(event.target); + // get leaf ID + let leafID = target.dataset.id; + // update URL to reflect the currently selected leaf; + // replace the location & state to avoid polluting browser history + history.replaceState(null, "", `#${leafID}`); + } + // regardless, update selection + this.updateSelection(); + } + + set currentTag(tag) { + // parse the curent url + let url = new URL(window.location.href); + // add/remove active tag indicator to container + // so css can be used to disable untagged leaves + + // if no tag passed in, remove active tag param + if (tag == undefined || tag == null) { + url.searchParams.delete("tag"); + this.container.classList.remove("tag-active"); + } else { + // if tag passed in, set it in url params + url.searchParams.set("tag", tag); + this.container.classList.add("tag-active"); + } + // update url in history + history.replaceState(null, "", url.toString()); + // update selection + this.updateSelection(); + } + + static getLeafTarget(target) { + // if the target is the tspan within text label, use parent element + if (target.tagName == "tspan") { + target = target.parentElement; + } + return target; + } + + static targetLeafURL(target) { + // both text and path have data-url set + return Leaf.getLeafTarget(target).dataset.url; + } + + static deselectCurrent() { + // deselect all selected and highlighted leaves and their labels + d3.selectAll(`.${Leaf.selectedClass}`).classed(Leaf.selectedClass, false); + d3.selectAll(`.${Leaf.highlightClass}`).classed(Leaf.highlightClass, false); + } + + get currentState() { + // get selection information from URL + let url = new URL(window.location.href); + let tag = url.searchParams.get("tag"); + let leafHash = url.hash; + + // construct an object to track current state + let currentState = {}; + if (tag) { + currentState.tag = tag; + } + + if (leafHash && leafHash.startsWith("#")) { + currentState.leaf = leafHash.slice(1); + } + return currentState; + } + + get currentLeaf() { + return this.currentState.leaf; + } + + get currentTag() { + return this.currentState.tag; + } + + updateSelection() { + // get selection information from URL + let currentState = this.currentState; + + // deselect any current + Leaf.deselectCurrent(); + + // undo selection for any previously active tags + d3.selectAll(".tags a").classed(Leaf.selectedClass, false); + // if a tag is active + if (currentState.tag) { + d3.selectAll(`.${currentState.tag}`).classed(Leaf.highlightClass, true); + + // disable all leaves and dedication for both mouse and keyboard users + d3.selectAll( + `svg [tabindex]:not(.${currentState.tag}):not(.branch-start)` + ) + .attr("tabindex", -1) + .attr("aria-disabled", true); + + // add indicator to container to dim the untagged portions of the tree + document.querySelector("body").classList.add("tag-active"); + + let activeTag = document.querySelector("#current-tag span"); + // display the tag name based on the slug; + // as fallback, display the tag id if there is no name found + let previousActiveTag = activeTag.textContent; + activeTag.textContent = this.tags[currentState.tag] || currentState.tag; + // when tag filter changes, announce for screen readers that + // the tree is filtered and how many leaves are highlighted + if (activeTag.textContent != previousActiveTag) { + let leafCount = document.querySelectorAll( + `path.${currentState.tag}` + ).length; + this.panel.announce( + `Filtering by tag ${activeTag.textContent}; ${leafCount} leaves highlighted` + ); + } + + // enable tag close button + let closeTag = document.querySelector("#current-tag button"); + if (closeTag) { + closeTag.removeAttribute("disabled"); + } + } else { + // otherwise, remove active tag + const body = document.querySelector("body"); + if (body.classList.contains("tag-active")) { + // announce when tag filter is removed + this.panel.announce("Tag filtering removed"); + } + + body.classList.remove("tag-active"); + + // disable active tag close button + let tagClose = document.querySelector("#current-tag button"); + if (tagClose) { + // not included in all unit test fixtures + tagClose.setAttribute("disabled", "true"); + } + + // re-enable all active leaves and dedication; + // do NOT activate branch-start pseudo-headers + d3.selectAll("svg [tabindex][aria-disabled=true]:not(.branch-start)") + .attr("tabindex", 0) + .attr("aria-disabled", null); + } + + // if hash set, select leaf; unless it is in the list of ignored ids + // (load leaf first so if there is a current tag it can be set to active) + if (currentState.leaf && !this.ignore_ids.includes(currentState.leaf)) { + let leafTarget = document.querySelector( + `path[data-id="${currentState.leaf}"], image[data-id="${currentState.leaf}"]` + ); + // if path is not found, look for an image (= dedication) + + // if hash id corresponds to a leaf, select it + if (leafTarget != undefined) { + // actually make selection + Leaf.setLeafLabelClass(leafTarget.dataset.url, Leaf.selectedClass); + // open panel + this.openLeafDetails(leafTarget, currentState.tag); + + // fixme: shouldn't need to be set here + document.body.dataset.panelvisible = true; + } + } + + // return an object indicating current state + return currentState; + } + + openLeafDetails(leafTarget, activeTag) { + this.panel.loadURL(leafTarget.dataset.url, (article) => { + // if an active tag is specifed, mark as selected + if (activeTag != undefined) { + let articleTag = article.querySelector( + `.tags a[data-tag=${activeTag}]` + ); + if (articleTag) { + articleTag.classList.add(Leaf.selectedClass); + } + } + }); + } + + static highlightLeaf(event) { + // visually highlight both leaf & label when corresponding one is hovered + Leaf.setLeafLabelClass(Leaf.targetLeafURL(event.target), "hover"); + } + + static unhighlightLeaf(event) { + // turn off visual highlight for both when hover ends + Leaf.setLeafLabelClass(Leaf.targetLeafURL(event.target), "hover", false); + } + + static setLeafLabelClass(leafURL, classname, add = true) { + // set a class on leaf and corresponding label based on data url + d3.selectAll(`[data-url="${leafURL}"]`).classed(classname, add); + } +} + +class LeafPath { + x = 0; + static curve = d3.line().curve(d3.curveNatural); + // stem = []; + leftPoints = []; + rightPoints = []; + // tip = []; + + constructor() { + this.height = this.getHeight(); + this.halfHeight = this.height / 2; + + // center of leaf should be at 0,0 + // middle of the leaf can be shifted +/- + let midXShift = plusOrMinus(leafSize.maxMidShift); + // left middle is center of leaf + half leaf width + random shift + this.leftMid = this.x - leafSize.width / 2 + midXShift; + let rightMid = this.leftMid + leafSize.width; + + this.tip = this.getTip(); + + // can we just add points to both sides and then sort by Y ? + + // generate points for left and right sides programmatically from + // top to bottom (stem to tip) with some randomness + + // collect leaf points as we go for the two sides of the leaf in tandem + // left side starts at the stem; append new points after + this.leftPoints = [ + this.stem, // top (stem) + ]; + // right side ends up at the stem; insert new points before + this.rightPoints = [this.stem]; + + // if leaf is long enough, do two mid points + if (this.height > 50) { + // two mid points around thirds + let midY1 = -this.height / 2 + this.height * 0.33 - randomNumBetween(7); + let midY2 = -this.height / 2 + this.height * 0.66 - randomNumBetween(7); + + // should we adjust the upper width? + if (cointoss()) { + // yes, adjust the first + // should we adjust the left or the right? + if (cointoss()) { + // left + // shrink width by adjustment on the leftside + let adjustment = randomNumBetween(7); + this.leftPoints.push([this.leftMid + adjustment, midY1]); + } else { + this.leftPoints.push([this.leftMid, midY1]); + } + + if (cointoss()) { + // right + let adjustment = randomNumBetween(7); + this.rightPoints.unshift([this.rightMid - adjustment, midY1]); + } else { + this.rightPoints.unshift([this.rightMid, midY1]); + } + } else { + // push first width without adjusting + this.leftPoints.push([this.leftMid, midY1]); + this.rightPoints.unshift([this.rightMid, midY1]); + + // adjust the second; should we adjust the left or the right? + let adjustment = randomNumBetween(5); + if (cointoss()) { + // left + // shrink width by adjustment on the leftside + this.leftPoints.push([this.leftMid + adjustment, midY2]); + } else { + this.leftPoints.push([this.leftMid, midY2]); + } + if (cointoss()) { + // right + this.rightPoints.unshift([this.rightMid - adjustment, midY2]); + } else { + this.rightPoints.unshift([this.rightMid, midY2]); + } + } + } else { + // for shorter leaves, only one set of midpoints + // Y-axis midpoint is 0 + this.leftPoints.push([this.leftMid, 0]); + this.rightPoints.unshift([rightMid, 0]); + } + + // how pointy is the tail? add points relative to the tail + // at 80% of the height, leaf should be on average 60% of the width; adjust randomly + let nearTailWidth = leafSize.width * 0.6 - randomNumBetween(7); + // let nearTailX = tailX - nearTailWidth; // randomNumBetween(nearTailWidth, - nearTailWidth); + // unadajusted tailx + let nearTailX = this.leftMid + leafSize.width / 2 - nearTailWidth / 2; // randomNumBetween(nearTailWidth, - + // let nearTailY = this.height * 0.8 - this.height/2; + let nearTailY = this.tip[1] - this.height * 0.2; + if (cointoss()) { + this.leftPoints.push([nearTailX, nearTailY]); + } else { + if (cointoss()) { + this.rightPoints.unshift([nearTailX + nearTailWidth, nearTailY]); + } + } + + // tail only needs to be added once + this.leftPoints.push(this.tip); //[tailX, leafHeight]); + + this.rightPoints.unshift(this.tip); //[tailX, leafHeight]); + } + + get stem() { + // stem is half the leaf height above 0 + return [this.x, -this.halfHeight]; + } + + getHeight() { + // generate a random height somewhere between our min and max + return randomNumBetween(leafSize.maxHeight, leafSize.minHeight); + } + + getTip() { + // tail of the leaf does not need to be centered + // start with midpoint of shifted middle, then shift slightly more + let tailX = + this.leftMid + leafSize.width / 2 + plusOrMinus(leafSize.maxTipShift); + return [tailX, this.halfHeight]; + } + + get rightMid() { + return this.leftMid + leafSize.width; + } + + get points() { + return [ + this.stem, + [this.leftMid, 0], + this.tip, + [this.rightMid, 0], + this.stem, + ]; + } + + // get leftPoints() { + // return [this.stem, [this.leftMid, 0], this.tip]; + // } + + // get rightPoints() { + // return [this.tip, [this.rightMid, 0], this.stem]; + // } + + get path() { + // combine two curves so the tip doesn't get too curved + return LeafPath.curve(this.leftPoints) + LeafPath.curve(this.rightPoints); + // NOTE: two paths makes the outlines visibly disconnected... + // return LeafPath.curve(this.points); + } +} + +export { cointoss, leafSize, plusOrMinus, randomNumBetween, Leaf, LeafPath }; diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 00000000..64353edf --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,11 @@ +import { TimeTree } from "./timetree"; + +// load & parse leaf data from json embedded in the document +const data = JSON.parse(document.querySelector(".leaf-data").value); +// load and parse tag list for label / slug lookup label based on slug +const tags = JSON.parse(document.querySelector(".tag-data").value); +// determine if this is a production or development build +const params = JSON.parse(document.querySelector(".env-data").value); + +// pass in leaf data, tag list, and whether debugging should be enabled +let timetree = new TimeTree(data, tags, params); diff --git a/assets/js/panel.js b/assets/js/panel.js new file mode 100644 index 00000000..4e1a1956 --- /dev/null +++ b/assets/js/panel.js @@ -0,0 +1,197 @@ +import { select, selectAll } from "d3-selection"; + +import { Leaf } from "./leaves"; + +// combine into d3 object for convenience +const d3 = { + select, + selectAll, +}; + +const PanelCloseEvent = new Event("panel-close"); + +class Panel { + /* + The panel is used to display the project introduction on page load + and leaf details as leaves are selected. + On desktop, leaf details can be closed but the panel is always visible. + On mobile, the panel can be closed completely; there is an info + button to redisplay the project introduction. + + The class `closed` is used to indicate the panel is closed + (hidden on mobile, display introduction on desktop); the class + `show-details` is used to indicate whether leaf details are visible or not. + + */ + + constructor() { + // get a reference to the panel element + this.el = document.querySelector("#leaf-details"); + this.container = this.el.parentElement; + this.infoButton = document.querySelector("header .info"); + this.bindHandlers(); + // aria live container for updates in status + this.status = this.container.querySelector("[role=status]"); + } + + open(showDetails = true) { + // on mobile, ensure the body is scrolled to the top before opening + window.scrollTo(0, 0); + + // open the panel; show leaf details by default + // disable the info button + document.body.dataset.panelvisible = true; + + // when show details is not true, ensure leaf details are hidden + d3.select(this.container) + .classed("show-details", showDetails) + .classed("closed", false); + + // disable the info button; inactive when the intro is visible + this.infoButton.disabled = true; + + // transfer focus to the panel + this.container.querySelector("#panel").focus(); + + // fixme: doesn't work ? + document.body.dataset.panelvisible = true; + } + + get detailsVisible() { + return this.container.classList.contains("show-details"); + } + + close(closeDetails = true) { + // close the intro and enable the info button + // by default, closes panel entireuly + + // determine if leaf details are currently displayed + // use dataset.showing ? + let leafVisible = this.detailsVisible; + + // if leaf details are visible and close details is true, + // deselect the leaf currently displayed, close that also, + // unless closeDetails has been disabled; + // (has a side effect of also removing any currently selected tag) + if (leafVisible && closeDetails) { + this.container.classList.remove("show-details"); + // clear out stored value for loaded url + delete this.el.dataset.showing; + this.el.dispatchEvent(PanelCloseEvent); + } + + // if we are closing everything or no leaf is visible, close the panel + if (closeDetails || !leafVisible) { + this.container.classList.add("closed"); + // update attribute on body to control overflow behavior + document.body.dataset.panelvisible = false; + } + + // enable the info button; + // provides a way to get back to the intro on mobile + this.infoButton.disabled = false; + } + + showIntro() { + // dispatch panel-close event; + // handler in leaf code will close leaf details + // (showing the intro implies closing leaf details if a leaf is selected) + this.el.dispatchEvent(PanelCloseEvent); + // open the panel without showing leaf details section + this.open(false); + } + + closeIntro() { + // close the panel without closing leaf details + this.close(false); + } + + announce(content) { + // update the contents of the aria live region + // to make polite announcements for screen reader users + this.status.textContent = content; + } + + loadURL(url, callback) { + // load specified url; display article contents in the panel + // takes an optional callback; if defined, will be applied to + // the url contents before inserting + + // if the requested url is already showing, do nothing + if (this.el.dataset.showing && this.el.dataset.showing == url) { + return; + } + // If you need to test load failure, uncomment this + // if (Math.random() < 0.5) { + // url = url + "xxx"; + // } + // console.log("fetching:", url); + + fetch(url) + .then((response) => { + if (!response.ok) { + return Promise.reject(response); + } + return response; + }) + .then((response) => response.text()) + .then((html) => { + let parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + // Get the article content and insert into panel + const article = doc.querySelector("article"); + if (callback != undefined) { + callback(article); + } + this.el.querySelector("article").replaceWith(article); + // store loaded url in data attribute for reload check + this.el.dataset.showing = url; + }) + .catch((response) => { + // if the request failed, display error article + let errorArticle = document.querySelector("#loaderror").cloneNode(true); + this.el.querySelector("article").replaceWith(errorArticle); + }); + + // scroll to the top, in case previous leaf was scrolled + this.el.scrollTop = 0; + + // ensure the panel is open with details shown + this.open(); + } + + bindHandlers() { + // bind click event handler for panel close button + document + .querySelector("aside .close") + .addEventListener("click", this.close.bind(this)); + + this.infoButton.addEventListener("click", this.showIntro.bind(this)); + + // bind a delegated click handler to handle scrolling on footnote links + const asideContainer = document.querySelector("aside"); + asideContainer.addEventListener( + "click", + this.handleFootnoteLinkClick.bind(this) + ); + } + + handleFootnoteLinkClick(event) { + // control scrolling for footnote links, since on mobile + // in some browsers it scrolls the entire page rather than the article div + let element = event.target; + if ( + element.tagName == "A" && + (element.classList.contains("footnote-ref") || + element.classList.contains("footnote-backref")) + ) { + event.preventDefault(); + event.stopPropagation(); + let ref = element.getAttribute("href").slice(1); + let el = document.getElementById(ref); + el.closest("article").scrollTop = el.offsetTop - 70; + } + } +} + +export { Panel }; diff --git a/assets/js/roots.js b/assets/js/roots.js new file mode 100644 index 00000000..324fa548 --- /dev/null +++ b/assets/js/roots.js @@ -0,0 +1,5 @@ +import { Roots } from "./branches"; + +window.addEventListener("load", (event) => { + new Roots(); +}); diff --git a/assets/js/timetree.js b/assets/js/timetree.js new file mode 100644 index 00000000..8991e810 --- /dev/null +++ b/assets/js/timetree.js @@ -0,0 +1,964 @@ +import { select, selectAll } from "d3-selection"; +import { axisLeft } from "d3-axis"; +import { scaleLinear } from "d3-scale"; +import { + forceSimulation, + forceManyBody, + forceCollide, + forceLink, + forceX, + forceY, +} from "d3-force"; +import { line, curveNatural } from "d3-shape"; +import { zoom, zoomIdentity, zoomTransform } from "d3-zoom"; +import { drag } from "d3-drag"; + +import { LeafLabel } from "./labels"; +import { Panel } from "./panel"; +import { Leaf, LeafPath, leafSize, randomNumBetween } from "./leaves"; +import { drawTreeSegment, drawTrunk, drawBranches } from "./branches"; +import { BaseSVG } from "./utils"; +import { TimeTreeKeysMixin } from "./keys"; + +// combine d3 imports into a d3 object for convenience +const d3 = { + axisLeft, + select, + selectAll, + forceSimulation, + forceManyBody, + forceCollide, + forceLink, + forceX, + forceY, + line, + curveNatural, + zoom, + zoomIdentity, + zoomTransform, + scaleLinear, + drag, +}; + +// branches are defined and should be displayed in this order +const branches = { + "Lands + Waters": "lands-waters", + Communities: "communities", + "The University": "university", + Removals: "removals", + "Resistance + Resurgence": "resistance-resurgence", +}; + +// branch style color sequence; set class name and control with css +function getBranchStyle(branchName) { + let branchSlug = branches[branchName]; + if (branchSlug != undefined) { + return `branch-${branchSlug}`; + } +} + +// strength of the various forces used to lay out the leaves +const forceStrength = { + // standard d3 forces + charge: -15, // simulate gravity (attraction) if the strength is positive, or electrostatic charge (repulsion) if the strength is negative + + // custom y force for century + centuryY: 10, // draw to Y coordinate for center of assigned century band + + // custom x force for branch + branchX: 0.4, // draw to X coordinate based on branch + + // strength of link force by type of link + leafToBranch: 4, // between leaf and branch-century node + branchToBranch: 3, // between branch century nodes +}; + +class TimeTree extends TimeTreeKeysMixin(BaseSVG) { + constructor(data, tags, params) { + super(); // call base svg constructor + + this.leaves = data.leaves; // input leaf data from json generated by hugo + this.leafStats = data.stats; // summary info generated by hugo + this.tags = tags; // dict of tags keyed on slug + // debugging should only be enabled when configured by hugo site param + this.debug = params.visual_debug === true; + if (this.debug) { + this.checkLeafData(); + } + + // TODO: does it make sense to use branches hugo data ? + // console.log(params.branches); + + this.leavesByBranch = this.sortAndGroupLeaves(); + this.network = this.generateNetwork(); + + this.panel = new Panel(); + // pass in panel reference, list of ids to ignore on hash change + // (i.e., slugs for branches), and tag list + this.leafmanager = new Leaf(this.panel, Object.values(branches), tags); + + this.drawTimeTree(); + + // update selection to reflect active tag and/or leaf hash in url on page load + let status = this.leafmanager.updateSelection(); + + // special case: if a tag is selected without a leaf on page load, + // hide the intro panel + if (status.tag && !status.leaf) { + this.panel.close(); + // zoom in on the tagged leaves + this.zoomToTagged(); + } else if (status.leaf) { + // if a leaf is selected on load, close the intro + this.panel.closeIntro(); + + // if a tag is active, zoom in on tagged leaves + if (status.tag) { + this.zoomToTagged(); + } else { + // otherwise, zoom in on the selected leaf + this.zoomToSelectedLeaf(); + } + } + + // event handlers for adjusting zoom + // - when the panel is closed, do *nothing* + // this.panel.el.addEventListener("panel-close", this.resetZoom.bind(this)); + // - when a tag is selected, zoom out to see all tagged leaves + this.panel.el.parentElement.addEventListener( + "tag-select", + this.zoomToTagged.bind(this) + ); + // - when a tag is deselected, zoom back in on selected leaf + this.panel.el.parentElement.addEventListener( + "tag-deselect", + this.zoomToSelectedLeaf.bind(this) + ); + + // bind key handling logic; code in timetree keys mixin + this.bindKeypressHandler(); + } + + checkLeafData() { + // check and report on total leaves, unsortable leaves + console.log(`${this.leaves.length} total leaves`); + // NOTE: some sort dates set to empty string, "?"; 0 is allowed for earliest sort + let unsortableLeaves = this.leaves.filter( + (leaf) => leaf.sort_date === null || leaf.sort_date == "" + ); + console.log( + `${unsortableLeaves.length} lea${ + unsortableLeaves.length == 1 ? "f" : "ves" + } with sort date not set` + ); + } + + sortAndGroupLeaves() { + // sort the leaves by date, + // ignoring any records with sort date unset + let sortedLeaves = this.leaves + .filter((leaf) => leaf.sort_date != null) + .sort((a, b) => a.sort_date - b.sort_date); + // leaf century is included in json generated by Hugo + + // group leaves by branch, preserving sort order + return sortedLeaves.reduce((acc, leaf) => { + let b = leaf.branch; + leaf.type = "leaf"; + // check that branch is in our list + if (b in branches) { + if (acc[b] == undefined) { + acc[b] = []; + } + acc[b].push(leaf); + } else { + // report unknown branch and omit from the tree + // TODO: only report if not production... + console.log(`Unknown branch: ${b}`); + } + return acc; + }, {}); + } + + generateNetwork() { + // generate a network from the leaves + // so we can use a d3 force layout to positin them + + // create a list to add nodes, starting with a node for the trunk + let nodes = [ + { + id: "trunk", + title: "trunk", + type: "trunk", + }, + ]; + const trunkNodeIndex = 0; // first node is the trunk + + // array of links between our nodes + let links = new Array(); + + // add leaves to nodes by branch, in sequence; + // create branch+century nodes as we go + // make sure we follow canonical branch order, + // so logical dom order matches visual branch order + for (const branch in branches) { + // *in* for keys + // let currentBranchIndex; // = null; + let currentBranchNodeCount = 0; + let currentCentury; + let previousBranchIndex = trunkNodeIndex; + let branchIndex; + this.leavesByBranch[branch].forEach((leaf, index) => { + // check if we need to make a new branch node: + // - no node exists + // - too many leaves on current node + // - century has changed + if ( + branchIndex == undefined || + currentBranchNodeCount > 5 || + currentCentury != leaf.century + ) { + let branchId = `${branch}-century${leaf.century}-${index}`; + currentCentury = leaf.century; + currentBranchNodeCount = 0; + + let type = "branch"; + let id = `${branch}-century${leaf.century}-${index}`; + let title = `${branch} ${leaf.century}century (${index})`; + + // first node for each branch will be used to create a heading + if (branchIndex == undefined) { + type = "branch-start"; + title = branch; // branch name + id = branches[branch]; // slug for this branch + } + nodes.push({ + id: id, + title: title, + type: type, + branch: branch, + century: leaf.century, + sort_date: leaf.century * 100, // + 50, + }); + // add to links + branchIndex = nodes.length - 1; + // link to trunk or previous branch node + links.push({ + source: previousBranchIndex, + target: branchIndex, + value: forceStrength.branchToBranch, + branch: branch, + }); + + // store as previous branch for the next created branch + previousBranchIndex = branchIndex; + } + // add the current leaf as a node + leaf.label = new LeafLabel(leaf.display_title || leaf.title); + nodes.push(leaf); + currentBranchNodeCount += 1; + // add link between leaf and branch + let leafIndex = nodes.length - 1; + links.push({ + source: branchIndex, + target: leafIndex, + value: forceStrength.leafToBranch, + branch: leaf.branch, + }); + }); + } + + return { + nodes: nodes, + links: links, + }; + } + + drawTimeTree() { + let width = this.getSVGWidth(); // width depends on if mobile or not + let height = 800; + + // point [0, 0] is the center of the svg + let min_x = -width / 2; + let min_y = -height / 2; + // let [min_x, min_y] = [0, 0]; + + // store on the class instance for other methods + this.width = width; + this.height = height; + this.min_y = min_y; + this.min_x = min_x; + + let svg = d3 + .select("#timetree") + .append("svg") + .attr("viewBox", [min_x, min_y, width, height]); + + // create a group within the viz for the zoomable portion of the tree + // don't draw anything outside of the clip path + this.vizGroup = svg.insert("g", "#century-axis").attr("id", "#viz"); + + // create a section for the background + let background = this.vizGroup.append("g").attr("id", "background"); + + this.svg = svg; + this.background = background; + + // load graphic for plaque without strings + // position and make it look like a leaf for interaction + this.vizGroup + .append("image") + .attr("href", "/img/plaque-nostrings.svg#main") + .attr("aria-label", "dedication") + .attr("role", "button") + .attr("tabindex", 0) + .attr("id", "dedication") + .attr("data-id", "dedication") + .attr("data-url", "/dedication/") + .attr("transform", `translate(-70,220) scale(1.35)`) + .on("click", this.selectLeaf.bind(this)); + // add strings separately, for decoration only + this.vizGroup + .append("use") + .attr("href", "/img/plaque-strings.svg#main") + .attr("transform", `translate(-70,220) scale(1.35)`); + + // enable zooming + this.initZoom(); + + // create a y-axis for plotting the leaves by date + // let yAxisHeight = this.height * 0.6; // leafContainerHeight * this.centuries.length; + let yAxisHeight = 90 * 6; + let axisMin = this.min_y + 30; + + // now generating min/max years in hugo json data + let leafYears = [this.leafStats.maxYear, this.leafStats.minYear]; + // generate list of centuries from min year to up to max + let centuries = []; + let century = Math.round(this.leafStats.minYear, 100); + while (century < this.leafStats.maxYear) { + centuries.push(century); + century += 100; + } + + // create a linear scale to map years to svg coordinates + this.yScale = d3 + .scaleLinear() + .domain(leafYears) // max year to min year + .range([axisMin, axisMin + yAxisHeight]); // highest point on the chart to lowest point for leaves + + this.yAxis = d3 + .axisLeft(this.yScale) + .tickValues(centuries) // only display senturies + .tickFormat((x) => { + return x.toFixed() + "s"; + }); + + // axis is always drawn at origin, which for us is the center of the svg; + // move to the left with enough space for labels to show + let labelMargin = { x: 4, y: 10 }; + this.gYAxis = svg + .append("g") + .attr("id", "century-axis") + .attr("transform", `translate(${this.min_x + 15},0)`) + .call(this.yAxis) + .call((g) => g.attr("text-anchor", "start")) // override left axis default of end + .call((g) => g.select(".domain").remove()) + .call((g) => + g + .selectAll(".tick") + .append("rect") + .lower() + .attr("class", "tick-bg") + .attr("x", (d, i, n) => { + return n[i].parentElement.getBBox().x - labelMargin.x; + }) + .attr("y", (d, i, n) => { + return n[i].parentElement.getBBox().y - labelMargin.y; + }) + .attr("width", 60) + .attr("height", 38) + ); + + // determine placement for branches left to right + // NOTE: could this be construed as an axis of some kind? + this.branchCoords = {}; + let branchMargin = 100; + // etermine how much space to give to each branch + let branchWidth = (width - branchMargin * 2) / 5; + // calculate the midpoint of each branch and set for easy lookup + for (const [i, b] of Object.keys(branches).entries()) { + this.branchCoords[b] = + branchMargin + min_x + i * branchWidth + branchWidth / 2; + } + + this.trunkTop = this.yScale(this.leafStats.minYear - 15); + drawTrunk( + background, + [this.min_x, this.min_y, this.width, this.height], + this.trunkTop + ); + + // for debugging: mark the center of the svg + // svg + // .append("circle") + // .attr("r", 5) + // .attr("fill", "red") + // .attr("cx", this.min_x + this.width / 2) + // .attr("cy", this.min_y + this.height / 2);*/ + + // for debugging: mark the center bottom of the svg + // this.svg + // .append("circle") + // .attr("r", 5) + // .attr("fill", "red") + // .attr("cx", 0) + // .attr("cy", this.min_y + this.height); + + // for debugging: mark the bounds of the svg + // this.svg + // .append("rect") + // .attr("x", this.min_x) + // .attr("y", this.min_y) + // .attr("width", this.width) + // .attr("height", this.height) + // .attr("stroke", "red") + // .attr("fill", "none"); + + let simulation = d3 + .forceSimulation(this.network.nodes) + .force("charge", d3.forceManyBody().strength(forceStrength.charge)) + // .alpha(0.1) + .alphaDecay(0.2) + .force( + "collide", + d3.forceCollide().radius((d) => { + // collision radius varies by node type + if (d.type == "leaf") { + return leafSize.width; // - 5; + } + return 2; + }) + ) + .force( + "link", + d3.forceLink(this.network.links).strength((link) => { + return link.value; // link strength defined when links created + }) + ) + .force( + "y", + d3 + .forceY() + .y((node) => this.centuryY(node)) + .strength(forceStrength.centuryY) + ) + .force( + "x", + d3 + .forceX() + .x((node) => this.branchX(node)) + .strength((node) => { + if (node.century != undefined) { + // apply the force more strongly the further up the tree we go + return forceStrength.branchX * (node.century - 14); + } + return 0; + }) + ) + .force("bounds", this.leafBounds.bind(this)); + + // run simulation for 30 ticks without animation to set positions + // (30 is enough with alpha decay of 0.2; need 300 with default alpha decay) + simulation.tick(30); + + // define once an empty path for nodes we don't want to display + var emptyPath = d3.line().curve(d3.curveNatural)([[0, 0]]); + + let simulationNodes = this.vizGroup + .append("g") + .attr("class", "nodes") + .selectAll("path") + .data(this.network.nodes) + .join("path") + // draw leaf path for leaves, empty path for everything else + .attr("d", (d) => { + return d.type == "leaf" ? new LeafPath().path : emptyPath; + }) + // for accessibility purposes, leaves are buttons + .attr("role", (d) => { + if (d.type == "leaf") { + return "button"; + } else if (d.type == "branch-start") { + return "heading"; + } + }) + .attr("aria-level", (d) => { + return d.type == "branch-start" ? 2 : null; + }) + .attr("id", (d) => { + return d.type == "branch-start " ? d.id : null; + }) + .attr("aria-label", (d) => { + if (d.type == "leaf") { + return d.label.text; + } else if (d.type == "branch-start") { + return d.text; + } + }) + // reference description by id; short descriptions generated in hugo template + .attr("aria-describedby", (d) => { + return d.type == "leaf" ? `desc-${d.id}` : null; + }) + // make leaves and branch-start keyboard focusable + .attr("tabindex", (d) => { + if (d.type == "leaf") { + return 0; + } else if (d.type == "branch-start") { + return -1; // only focusable from link in legend + } + }) + .attr("stroke-linejoin", "bevel") + .attr("id", (d) => { + return d.type == "branch-start" ? d.id : null; + }) + .attr("data-id", (d) => d.id) + .attr("data-url", (d) => d.url || d.id) + .attr("data-sort-date", (d) => d.sort_date) + .attr("data-century", (d) => d.century) + .attr("class", (d) => { + let classes = [d.type, getBranchStyle(d.branch)]; + if (d.tags != undefined) { + classes.push(...d.tags); + } + return classes.join(" "); + }) + .on("click", this.selectLeaf.bind(this)) + .on("mouseover", Leaf.highlightLeaf) + .on("mouseout", Leaf.unhighlightLeaf); + + this.simulationNodes = simulationNodes; + + // add text labels for leaves; position based on the leaf node + this.nodeLabels = this.vizGroup + .append("g") + .attr("id", "labels") + .selectAll("text") + .data(this.network.nodes.filter((d) => d.type == "leaf")) + .join("text") + // x,y for a circle is the center, but for a text element it is top left + // set position based on x,y adjusted by radius and height + .attr("x", (d) => d.x - d.label.radius) + .attr("y", (d) => d.y - d.label.height / 2) + .attr("data-id", (d) => d.id) // leaf id for url state + .attr("data-url", (d) => d.url) // set url so we can click to select leaf + .attr("text-anchor", "middle") // set coordinates to middle of text + .attr("class", (d) => { + let classes = ["leaf-label"]; + if (d.tags != undefined) { + classes.push(...d.tags); + } + // a few leaves are marked as featured to show labels + // on mobile when not zoomed + if (d.featured) { + classes.push("featured"); + } + return classes.join(" "); + }) + // .text((d) => d.label.text) + .on("click", this.selectLeaf.bind(this)) + .on("mouseover", Leaf.highlightLeaf) + .on("mouseout", Leaf.unhighlightLeaf); + + // split labels into words and use tspans to position on multiple lines; + // inherits text-anchor: middle from parent text element + this.nodeLabels + .selectAll("tspan") + .data((d) => { + // split label into words, then return as a map so + // each element has a reference to the parent node + return d.label.parts.map((i) => { + return { word: i, node: d }; + }); + }) + .join("tspan") + .text((d) => d.word) + .attr("x", (d) => { + // position at the same x as the parent node + return d.node.x; + }) + .attr("dy", LeafLabel.lineHeight); // delta-y : relative position based on line height + + if (this.debug) { + this.visualDebug(); + } + + // only position once after simulation has run + simulation.on("tick", this.updatePositions.bind(this)); + simulation.tick(); + // simulation.stop(); + } + + leafBounds() { + // custom bounds force to make sure leaves stay within the svg bounds + // so that all leaves will always be visible + // adapted from https://stackoverflow.com/a/51315920/9706217 + + // apply a small margin to the edge of the svg and where leaves are allowed + const margin = 40; // ~ leaf width + const leaf_bounds = { + min_y: this.min_y + margin, + min_x: this.min_x + margin, + max_x: this.min_x + this.width - margin, + }; + + for (let node of this.network.nodes) { + // main concern is leaves going off the top of the svg + // limit y coordinate to our local min y + node.y = Math.max(leaf_bounds.min_y, node.y); + // also keep leaves from going off the sides in either direction + node.x = Math.max(leaf_bounds.min_x, Math.min(leaf_bounds.max_x, node.x)); + } + } + + maxZoom = 4; // maximum zoom level + + initZoom() { + // make svg zoomable + this.zoom = d3 + .zoom() + // by default, d3 uses window/DOM coordinates for zoom; + // for convenience & consistency, use svg coordinate system + .extent([ + [this.min_x, this.min_y], + [this.min_x + this.width, this.min_y + this.height], + ]) + .scaleExtent([1, this.maxZoom]) // limit number of zoom levels + // limit panning to the same extent so we don't zoom beyond the edges + .translateExtent([ + [this.min_x, this.min_y], + [this.min_x + this.width, this.min_y + this.height], + ]) + .filter( + // use filter to control whether zooming is enabled + this.isMobile.bind(this) + ) + .on("zoom", this.zoomed.bind(this)); + + // bind zooming behavior to d3 svg selection + this.svg.call(this.zoom); + + // bind drag behaviors for desktop + this.svg.call( + d3 + .drag() + .on("start", this.dragstarted.bind(this)) + .on("drag", this.dragged.bind(this)) + .on("end", this.dragended.bind(this)) + ); + + // bind zoom behaviors to zoom control buttons + this.zoomControls = { + reset: d3.select(".reset-zoom"), + in: d3.select(".zoom-in"), + out: d3.select(".zoom-out"), + }; + this.zoomControls.reset.on("click", this.resetZoom.bind(this)); + this.zoomControls.in.on("click", this.zoomIn.bind(this)); + this.zoomControls.out.on("click", this.zoomOut.bind(this)); + } + + resetZoom() { + this.svg.call(this.zoom.transform, d3.zoomIdentity); + } + + zoomIn() { + this.zoom.scaleBy(this.svg, 1.2); + } + + zoomOut() { + this.zoom.scaleBy(this.svg, 0.8); + } + + zoomed(event) { + // handle zoom event + + // if triggered by a real event (not a programmatic zoom), + // prevent default behavior + if (event.sourceEvent) { + event.sourceEvent.preventDefault(); // indicates this is a passive event listener + } + + // update century y-axis for the new scale + const transform = event.transform; + this.gYAxis.call(this.yAxis.scale(transform.rescaleY(this.yScale))); + const axisLabelTransform = Math.min(2.75, transform.k); + // zoom axis labels and backgrounds, but don't zoom all the way + this.gYAxis + .selectAll("text") + .attr("transform", `scale(${axisLabelTransform})`); + this.gYAxis + .selectAll(".tick-bg") + .attr("transform", `scale(${axisLabelTransform})`); + // translate the treeviz portion of the svg + this.vizGroup.attr("transform", transform); + + // set zoomed class on timetree container to control visibility of + // labels and reset button (hidden/disabled by default on mobile) + const container = this.svg.node().parentElement; + if (transform.k >= 1.2) { + // enable once we get past 1.2 zoom level + container.classList.add("zoomed"); + // reset and zoom out buttons are both enabled + this.zoomControls.reset.attr("disabled", null); + this.zoomControls.out.attr("disabled", null); + } else { + container.classList.remove("zoomed"); + // if not zoomed in, reset and zoom out are disabled + this.zoomControls.reset.attr("disabled", true); + this.zoomControls.out.attr("disabled", true); + } + // disable zoom-in button when we are maximum zoom + this.zoomControls.in.attr( + "disabled", + transform.k == this.maxZoom ? true : null + ); + } + + dragstarted() { + this.svg.attr("cursor", "grabbing"); + } + + dragged(event) { + // apply zoom translation based on the delta from the drag event + this.zoom.translateBy(this.svg, event.dx, event.dy); + } + + dragended() { + this.svg.attr("cursor", "grab"); + } + + selectLeaf(event, d) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + this.leafmanager.currentLeaf = event; + } + // TODO: on mobile, this should also scroll to the top of the page + this.panel.closeIntro(); // close so info button will be active on mobile + // zoom in on the data point for the selected leaf + if (event.target.id != "dedication") { + this.zoomToDatum(d); + } + } + + zoomToDatum(d, scale) { + // zoom to a specified point; takes a data entry with an x,y coordinate + // (generally a data point for a leaf used in the d3 force simulation) + // scale is optional; if not specified, will scale to maximum zoom level + + if (scale == undefined) { + scale = this.maxZoom; + } + + // programmatic zoom skips filters; check if mobile before auto-zooming + if (this.isMobile()) { + // for leaves lower on the tree, centering on the leaf coordinates + // displays the leaf under the info panel; focus below the leaf + // as long as we are not too close to the top + let adjusted_y = d.y; + if (d.y > -450) { + adjusted_y += 50; + } + let transform = d3.zoomIdentity.scale(scale); + // call the zoom handler to scale the century axis + this.zoomed({ transform }); + + // zooming is bound to the svg; + // scale and translate to the selected leaf or label + // scale to max zoom level using element coordinates as focus point. + this.zoom.scaleTo(this.svg, scale, [d.x, adjusted_y]); + + // transform to coordinates for the selected elment + this.zoom.translateTo(this.svg, d.x, adjusted_y); + } + } + + zoomToSelectedLeaf() { + // zoom in selected leaf on page load or when a tag is closed + if (this.isMobile()) { + // - determine which leaf is currently selected + let state = this.leafmanager.currentState; + // if a leaf is currently selected, find datum for the leaf id + if (state.leaf) { + let nodes = this.network.nodes.filter((d) => d.id == state.leaf); + if (nodes.length) { + this.zoomToDatum(nodes[0]); + } + } else { + // if no leaf is selected, reset zoom + this.resetZoom(); + } + } + } + + zoomToTagged() { + // zoom out the amount needed to show all leaves with the current tag + if (this.isMobile()) { + let state = this.leafmanager.currentState; + // get data points for all leaves with this tag + let nodes = this.network.nodes.filter( + (d) => d.tags != undefined && d.tags.includes(state.tag) + ); + // collect all the x and y coordinates and determine min and max + let nodesX = nodes.map((el) => el.x); + let nodesY = nodes.map((el) => el.y); + let [min_x, min_y, max_x, max_y] = [ + Math.min(...nodesX), + Math.min(...nodesY), + Math.max(...nodesX), + Math.max(...nodesY), + ]; + + let margin = 100; + let tagWidth = max_x - min_x; + let tagHeight = max_y - min_y; + // use ratio between full width and width needed to show all tagged items + // determine necessary zoom level; don't go beyond max zoom + let scale = Math.min(this.width / (tagWidth + margin), this.maxZoom); + // determine the center of the tags, for focusing the zoom + let tagCenter = { + x: min_x + tagWidth / 2, + y: min_y + tagHeight / 2, + }; + + // zoom in on the tags at the calculated scale + this.zoomToDatum(tagCenter, scale); + } + } + + visualDebug() { + // visual debugging for layout + // should be under other layers to avoid interfering with click/touch/hover + this.debugLayer = this.svg + .append("g") + .lower() // make this group the lowest in the stack + .attr("id", "debug") + .style("opacity", 0); // not visible by default + + // draw circles and lines in a debug layer that can be shown or hidden; + // circle size for leaf matches radius used for collision + // avoidance in the network layout + this.debugLayer + .selectAll("circle.debug") + .data(this.network.nodes) + .join("circle") + .attr( + "class", + (d) => `debug debug-${d.type} dbg-${getBranchStyle(d.branch) || ""}` + ) + .attr("cx", (d) => d.x) + .attr("cy", (d) => d.y) + .attr("r", (d) => { + if (d.type == "leaf") { + // return leafSize.width - 5; + return leafSize.width; + } + // note: this is larger than collision radius, increase size for visibility + return 5; // for branch nodes + }); + + // add lines for links within the network to the debug layer + this.simulationLinks = this.debugLayer + .append("g") + .selectAll("line") + .data(this.network.links) + .join("line") + .attr("class", (d) => { + return `${d.type || ""} dbg-${getBranchStyle(d.branch) || ""}`; + }); + + this.debugLayer + .selectAll("line.debug-branch") + .data(Object.keys(this.branchCoords)) + .join("line") + .attr("class", (d) => { + return `debug-branch-x dbg-${getBranchStyle(d)}`; + }) + .attr("x1", (d) => this.branchCoords[d]) + .attr("y1", this.min_y) + .attr("x2", (d) => this.branchCoords[d]) + .attr("y2", this.max_y); + + // add debug controls + let debugLayerControls = { + // control id => layer id + "debug-visible": "#debug", + "leaf-visible": ".nodes", + "label-visible": "#labels", + }; + + // When the debug range inputs change, update the opacity for + //the corresponding layer + d3.selectAll("#debug-controls input").on("input", function () { + d3.selectAll(debugLayerControls[this.id]).style( + "opacity", + `${this.value}%` + ); + }); + } + + centuryY(node) { + // y-axis force to align nodes by century + if (node.sort_date) { + return this.yScale(node.sort_date); + } + return 0; + } + + branchX(node) { + // x-axis force to align branches left to right based on branch + if (node.branch !== undefined) { + return this.branchCoords[node.branch]; + } + return 0; + } + + updatePositions() { + // since nodes are paths and not circles, position using transform + translate + // rotate leaves to vary the visual display of leaves + this.simulationNodes.attr("transform", (d, i, n) => { + // current datum (d), the current index (i), and the current group (nodes) + // generate a random rotation once and store it in the data + if (!d.rotation) { + // store rotation so we don't randomize every tick + d.rotation = randomNumBetween(125); // Math.random() * 90; + } + let rotation = d.rotation; + if (d.type == "leaf") { + // rotate negative or positive depending on side of the tree + if (d.x > 0) { + rotation = 0 - rotation; + } + // leaf coordinates are be centered around 0,0 + return `rotate(${rotation} ${d.x} ${d.y}) translate(${d.x} ${d.y})`; + } + // rotate relative to x, y, and move to x, y + return `translate(${d.x} ${d.y})`; + }); + + // links are only displayed for debugging + if (this.debug) { + this.simulationLinks + .attr("x1", (d) => d.source.x) + .attr("y1", (d) => d.source.y) + .attr("x2", (d) => d.target.x) + .attr("y2", (d) => d.target.y); + } + + // draw branches based on the network + drawBranches(this.network.nodes, this.vizGroup, branches, this.trunkTop); + } +} + +export { TimeTree, forceStrength, getBranchStyle }; diff --git a/assets/js/utils.js b/assets/js/utils.js new file mode 100644 index 00000000..4878d9bd --- /dev/null +++ b/assets/js/utils.js @@ -0,0 +1,21 @@ +// base SVG class with common functionality for timetree and roots +class BaseSVG { + // create a media query element to check for mobile / desktop + // using breakpoint-s (tablet in portrait orientation) + mobileQuery = window.matchMedia("(max-width: 790px)"); + + isMobile() { + return this.mobileQuery.matches; // true if our query matches + } + + width_opts = { + mobile: 800, + desktop: 1200, + }; + + getSVGWidth() { + return this.isMobile() ? this.width_opts.mobile : this.width_opts.desktop; + } +} + +export { BaseSVG }; diff --git a/test/js/labels.test.js b/test/js/labels.test.js new file mode 100644 index 00000000..e9f6b658 --- /dev/null +++ b/test/js/labels.test.js @@ -0,0 +1,44 @@ +import { + LeafLabel, + splitLabel, + labelRadius, + pixelsPerChar, + labelLineHeight, +} from "labels"; + +describe("LeafLabel", () => { + const labelText = "munsee sisters farm"; + test("init sets label", () => { + const label = new LeafLabel(labelText); + expect(label.text).toEqual(labelText); + }); + test("parts returns label split into words", () => { + const label = new LeafLabel(labelText); + expect(label.parts).toEqual(["munsee", "sisters", "farm"]); + }); + test("parts handles missing label", () => { + const label = new LeafLabel(); + expect(label.parts).toEqual(["no title"]); + }); + test("calculates height based on parts and line height", () => { + const label = new LeafLabel(labelText); + expect(label.height).toEqual(label.parts.length * LeafLabel.lineHeight); + }); + test("calculates width based on longest word", () => { + const word = "Shawukukhkung"; + const label = new LeafLabel(word); + expect(label.width).toEqual(word.length * LeafLabel.pixelsPerChar); + }); + test("calculates radius based on line height for short words", () => { + // one word only, should use line height + const oneWordLabel = new LeafLabel("a"); + expect(oneWordLabel.radius).toEqual(LeafLabel.lineHeight / 2.0); + // two words + const twoWordLabel = new LeafLabel("a bc"); + expect(twoWordLabel.radius).toEqual((LeafLabel.lineHeight * 2) / 2.0); + }); + test("calculates radius based on word length for long words", () => { + const word = "Shawukukhkung"; + // expect(new LeafLabel(word).radius).toEqual(word.length * LeafLabel.pixelsPerChar / 2.0); + }); +}); diff --git a/test/js/leaves.test.js b/test/js/leaves.test.js new file mode 100644 index 00000000..58d25fbd --- /dev/null +++ b/test/js/leaves.test.js @@ -0,0 +1,266 @@ +import { enableFetchMocks } from "jest-fetch-mock"; + +import { + leafSize, + drawLeaf, + Leaf, + plusOrMinus, + cointoss, + randomNumBetween, +} from "leaves"; +import { Panel } from "panel"; + +jest.mock("panel"); // Panel is now a mock constructor + +enableFetchMocks(); + +describe("randomNumBetween", () => { + test("returns number between 0 and specified max", () => { + const result = randomNumBetween(10); + expect(result).toBeLessThanOrEqual(10); + expect(result).toBeGreaterThanOrEqual(0); + }); + + test("returns number between specified max and specified min", () => { + const result = randomNumBetween(5, -5); + expect(result).toBeLessThanOrEqual(5); + expect(result).toBeGreaterThanOrEqual(-5); + + const result2 = randomNumBetween(10, 5); + expect(result2).toBeLessThanOrEqual(10); + expect(result2).toBeGreaterThanOrEqual(5); + }); +}); + +describe("plusOrMinus", () => { + test("returns number between positive and negative specified value", () => { + expect(plusOrMinus(3)).toBeLessThanOrEqual(3); + expect(plusOrMinus(10)).toBeGreaterThanOrEqual(-10); + }); +}); + +describe("cointoss", () => { + test("returns true or false", () => { + expect([true, false]).toContain(cointoss()); + }); +}); + +describe("leafSize", () => { + test("has expected properties", () => { + expect(leafSize).toHaveProperty("width"); + expect(leafSize).toHaveProperty("minHeight"); + expect(leafSize).toHaveProperty("maxHeight"); + }); +}); + +describe("Leaf", () => { + describe("deselectCurrent", () => { + test("removes selected class from leaves and labels", () => { + // Set up document body with selected leaves and labels + document.body.innerHTML = + "
" + + ' ' + + ' label />' + + "<