UNPKG

@uwdata/mosaic-plot

Version:

A Mosaic-powered plotting framework based on Observable Plot.

267 lines (233 loc) 7.6 kB
import { parsePath } from './parse-path.js'; /** * Return SVG elements that intersect the provided spatial extent. * @param {SVGSVGElement} svg Parent SVG element to search within. * @param {SVGElement} el SVG container element to search for points. * @param {[[number, number], [number, number]]} extent Rectangular brush * extent within which to select elements. The extent is represented as * upper-left and bottom-right (x, y) coordinates. * @returns {Element[]} A list of intersecting SVG elements. */ export function intersect(svg, el, extent) { // svg origin in viewport coordinates const { x, y } = svg.getBoundingClientRect(); const identity = svg.createSVGMatrix(); // collect elements that intersect the extent const list = []; for (const child of el.children) { if (child.tagName === 'g') { // handle faceted mark const matrix = getTransformMatrix(child) ?? identity; for (const grandchild of child.children) { if (intersects(extent, x, y, grandchild, matrix)) { list.push(grandchild); } } } else if (intersects(extent, x, y, child, identity)) { list.push(child); } } return list; } function intersects(sel, ox, oy, el, matrix) { let [[l, t], [r, b]] = sel; // facet groups involve translation only const { e: tx, f: ty } = matrix; // getBoundingClientRect uses viewport coordinates // so we first translate to SVG coordinates const c = el.getBoundingClientRect(); const cl = c.left - ox; const cr = c.right - ox; const ct = c.top - oy; const cb = c.bottom - oy; if (cl >= l && cr <= r && ct >= t && cb <= b) { // if selection encloses item bounds, we're done return true; } else if (cl <= r && cr >= l && ct <= b && cb >= t) { // if selection intersects item bounds, test further let tag = el.tagName; // if a hyperlink, use enclosed element if (tag === 'a') { el = el.children[0]; tag = el.tagName; } // handle marks that rely solely on bbox intersection if (tag === 'rect' || tag === 'text' || tag === 'image') { return true; } // translate selection relative to enclosing group l -= tx; t -= ty; r -= tx; b -= ty; switch (tag) { case 'circle': return intersectCircle(l, t, r, b, $(el.cx), $(el.cy), $(el.r)); case 'line': return intersectLine(l, t, r, b, $(el.x1), $(el.y1), $(el.x2), $(el.y2)); case 'path': return intersectPath(l, t, r, b, el); } } return false; } function $(attr) { return attr.baseVal.value; } function getTransformMatrix(el) { const transform = el.transform.baseVal; const n = transform.length; let m = transform[0]?.matrix; for (let i = 1; i < n; ++i) { m = m.multiply(transform[i].matrix); } return m; } function intersectCircle(l, t, r, b, cx, cy, cr) { const h = l <= cx && cx <= r; const v = t <= cy && cy <= b; if (h && v) return true; // center is enclosed const dx = Math.min(Math.abs(l - cx), Math.abs(r - cx)); if (v && dx <= cr) return true; const dy = Math.min(Math.abs(t - cy), Math.abs(b - cy)); return (h && dy <= cr) || (dx * dx + dy * dy <= cr * cr); } function intersectLine(l, t, r, b, x1, y1, x2, y2) { const xmin = Math.max(Math.min(x1, x2), l); const xmax = Math.min(Math.max(x1, x2), r); if (xmin > xmax) return false; let yl1 = y1; let yl2 = y2; const dx = x2 - x1; if (Math.abs(dx) > 1e-8) { const a = (y2 - y1) / dx; const b = y1 - a * x1; yl1 = a * xmin + b; yl2 = a * xmax + b; } const ymin = Math.max(Math.min(yl1, yl2), t); const ymax = Math.min(Math.max(yl1, yl2), b); return ymin <= ymax; } export function intersectPath(l, t, r, b, el) { // parse path and cache result for reuse const cmds = el.__path__ || (el.__path__ = parsePath(el.getAttribute('d'))); let anchorX = 0; let anchorY = 0; let x = 0; let y = 0; let hit = false; let poly = [0, 0]; let n = 2; const matrix = getTransformMatrix(el); const setAnchor = (ax, ay) => { poly.length = n = 2; poly[0] = x = anchorX = ax; poly[1] = y = anchorY = ay; }; const anchor = matrix ? (x, y) => setAnchor(multiplyX(matrix, x, y), multiplyY(matrix, x, y)) : (x, y) => setAnchor(x, y); const test = (x2, y2) => { poly[n] = x2; poly[n+1] = y2; n += 2; return intersectLine(l, t, r, b, poly[n-4], poly[n-3], x2, y2); } const lineTo = matrix ? (x2, y2) => { hit = test( multiplyX(matrix, x = x2, y = y2), multiplyY(matrix, x2, y2) ); } : (x2, y2) => { hit = test(x = x2, y = y2); }; for (let i = 0; i < cmds.length; ++i) { const cmd = cmds[i]; switch (cmd[0]) { case 'M': anchor(cmd[1], cmd[2]); break; case 'm': anchor(x + cmd[1], y + cmd[2]); break; case 'L': case 'T': lineTo(cmd[1], cmd[2]); break; case 'H': lineTo(cmd[1], y); break; case 'V': lineTo(x, cmd[1]); break; case 'l': case 't': lineTo(x + cmd[1], y + cmd[2]); break; case 'h': lineTo(x + cmd[1], y); break; case 'v': lineTo(x, y + cmd[1]); break; // approximate bezier curve as line for now case 'C': lineTo(cmd[5], cmd[6]); break; case 'c': lineTo(x + cmd[5], y + cmd[6]); break; case 'S': case 'Q': lineTo(cmd[3], cmd[4]); break; case 's': case 'q': lineTo(x + cmd[3], y + cmd[4]); break; // we don't expect to see arcs other than geo point circles // but just in case, approximate via straight line for now case 'A': lineTo(cmd[6], cmd[7]); break; case 'a': if (isCircle(cmds, i)) { // special case for geo point circle return intersectCircle(l, t, r, b, x, y - cmd[2], cmd[2]); } else { lineTo(x + cmd[6], x + cmd[7]); } break; case 'z': case 'Z': lineTo(anchorX, anchorY); if (pointInPolygon(l, t, poly) > 0) return true; anchor(anchorX, anchorY); break; default: // bail for now console.warn('SVG path command not supported: ', cmd[0]); return false; } if (hit) return true; } return false; } function multiplyX(m, x, y) { return m.a * x + m.c * y + m.e; } function multiplyY(m, x, y) { return m.b * x + m.d * y + m.f; } function isCircle(cmds, i) { const a = cmds[i]; const b = cmds[i+1]; return b && b[0] === 'a' && cmds[i+2]?.[0] === 'z' && a[1] === a[2] && b[1] === b[2] && a[1] === b[1] && a[7] === -b[7]; } /** * Point in polygon test, based on Dan Sunday's winding number algorithm. * https://web.archive.org/web/20130126163405/http://geomalgorithms.com/a03-_inclusion.html * @param {number} x The x-coordinate to test for inclusion * @param {number} y The y-coordinate to test for inclusion * @param {number[]} poly Polygon vertices as a flat array of numbers * @returns {number} The winding number. Non-zero values indicate inclusion. */ function pointInPolygon(x, y, poly) { let wn = 0; const n = poly.length - 2; for (let i = 0; i < n; i += 2) { if (poly[i + 1] <= y) { // an upward crossing and (x,y) left of edge if (poly[i + 3] > y && isLeft(x, y, poly, i) > 0) ++wn; // valid up intersect } // a downward crossing and (x,y) right of edge else if (poly[i + 3] <= y && isLeft(x, y, poly[i]) < 0) { --wn; // valid down intersect } } return wn; } function isLeft(x, y, p, i) { return (p[i+2] - p[i]) * (y - p[i+1]) - (x - p[i]) * (p[i+3] - p[i+1]); }