diff --git a/lib/src/components/Header.tsx b/lib/src/components/Header.tsx index 05206e72..47760e7b 100644 --- a/lib/src/components/Header.tsx +++ b/lib/src/components/Header.tsx @@ -30,7 +30,7 @@ const InfoArea = observer(({ model }: { model: MsaViewModel }) => { ) }) -const Header = observer(({ model }: { model: MsaViewModel }) => { +const Header = observer(function ({ model }: { model: MsaViewModel }) { const [settingsDialogViz, setSettingsDialogViz] = useState(false) const [aboutDialogViz, setAboutDialogViz] = useState(false) const [detailsDialogViz, setMetadataDialogViz] = useState(false) diff --git a/lib/src/components/TreeCanvasBlock.tsx b/lib/src/components/TreeCanvasBlock.tsx index 64f3d940..18d40471 100644 --- a/lib/src/components/TreeCanvasBlock.tsx +++ b/lib/src/components/TreeCanvasBlock.tsx @@ -6,10 +6,11 @@ import RBush from 'rbush' import { MsaViewModel } from '../model' import TreeMenu from './TreeMenu' import TreeBranchMenu from './TreeBranchMenu' - -const extendBounds = 5 -const radius = 3.5 -const d = radius * 2 +import { + renderNodeBubbles, + renderTree, + renderTreeLabels, +} from './renderTreeCanvas' const padding = 600 @@ -83,144 +84,20 @@ const TreeCanvasBlock = observer(function ({ ctx.font = font.replace(/\d+px/, `${Math.max(8, rowHeight - 8)}px`) if (!noTree && drawTree) { - for (const { source, target } of hierarchy.links()) { - const y = showBranchLen ? 'len' : 'y' - // @ts-expect-error - const { x: sy, [y]: sx } = source - // @ts-expect-error - const { x: ty, [y]: tx } = target - - const y1 = Math.min(sy, ty) - const y2 = Math.max(sy, ty) - // 1d line intersection to check if line crosses block at all, this is - // an optimization that allows us to skip drawing most tree links - // outside the block - if (offsetY + blockSize >= y1 && y2 >= offsetY) { - ctx.beginPath() - ctx.moveTo(sx, sy) - ctx.lineTo(sx, ty) - ctx.lineTo(tx, ty) - ctx.stroke() - } - } + renderTree({ ctx, offsetY, model }) if (drawNodeBubbles) { - for (const node of hierarchy.descendants()) { - const val = showBranchLen ? 'len' : 'y' - const { - // @ts-expect-error - x: y, - // @ts-expect-error - [val]: x, - data, - } = node - const { id = '', name = '' } = data - - if ( - y > offsetY - extendBounds && - y < offsetY + blockSize + extendBounds - ) { - ctx.strokeStyle = 'black' - ctx.fillStyle = collapsed.includes(id) ? 'black' : 'white' - ctx.beginPath() - ctx.arc(x, y, radius, 0, 2 * Math.PI) - ctx.fill() - ctx.stroke() - - clickMap.current.insert({ - minX: x - radius, - maxX: x - radius + d, - minY: y - radius, - maxY: y - radius + d, - branch: true, - id, - name, - }) - } - } + renderNodeBubbles({ ctx, offsetY, clickMap: clickMap.current, model }) } } if (rowHeight >= 5) { - if (labelsAlignRight) { - ctx.textAlign = 'right' - ctx.setLineDash([1, 3]) - } else { - ctx.textAlign = 'start' - } - for (const node of hierarchy.leaves()) { - const { - // @ts-expect-error - x: y, - // @ts-expect-error - y: x, - data: { name, id }, - // @ts-expect-error - len, - } = node - - const displayName = treeMetadata[name]?.genome || name - - if ( - y > offsetY - extendBounds && - y < offsetY + blockSize + extendBounds - ) { - // note: +rowHeight/4 matches with -rowHeight/4 in msa - const yp = y + rowHeight / 4 - const xp = showBranchLen ? len : x - - const { width } = ctx.measureText(displayName) - const height = ctx.measureText('M').width // use an 'em' for height - - const hasStructure = structures[name] - ctx.fillStyle = hasStructure ? 'blue' : 'black' - - if (!drawTree && !labelsAlignRight) { - ctx.fillText(displayName, 0, yp) - clickMap.current.insert({ - minX: 0, - maxX: width, - minY: yp - height, - maxY: yp, - name, - id, - }) - } else if (labelsAlignRight) { - const smallPadding = 2 - const offset = treeAreaWidth - smallPadding - margin.left - if (drawTree && !noTree) { - const { width } = ctx.measureText(displayName) - ctx.moveTo(xp + radius + 2, y) - ctx.lineTo(offset - smallPadding - width, y) - ctx.stroke() - } - ctx.fillText(displayName, offset, yp) - clickMap.current.insert({ - minX: treeAreaWidth - margin.left - width, - maxX: treeAreaWidth - margin.left, - minY: yp - height, - maxY: yp, - name, - id, - }) - } else { - ctx.fillText(displayName, xp + d, yp) - clickMap.current.insert({ - minX: xp + d, - maxX: xp + d + width, - minY: yp - height, - maxY: yp, - name, - id, - }) - } - } - } - ctx.setLineDash([]) + renderTreeLabels({ ctx, offsetY, model, clickMap: clickMap.current }) } }, [ collapsed, rowHeight, + model, margin.left, hierarchy, offsetY, diff --git a/lib/src/components/renderTreeCanvas.ts b/lib/src/components/renderTreeCanvas.ts new file mode 100644 index 00000000..cf9ac5b8 --- /dev/null +++ b/lib/src/components/renderTreeCanvas.ts @@ -0,0 +1,192 @@ +import RBush from 'rbush' + +// locals +import { MsaViewModel } from '../model' + +const extendBounds = 5 +const radius = 3.5 +const d = radius * 2 + +interface ClickEntry { + name: string + id: string + branch?: boolean + minX: number + maxX: number + minY: number + maxY: number +} + +export function renderTree({ + offsetY, + ctx, + model, +}: { + offsetY: number + ctx: CanvasRenderingContext2D + model: MsaViewModel +}) { + const { hierarchy, showBranchLen, blockSize } = model + for (const { source, target } of hierarchy.links()) { + const y = showBranchLen ? 'len' : 'y' + // @ts-expect-error + const { x: sy, [y]: sx } = source + // @ts-expect-error + const { x: ty, [y]: tx } = target + + const y1 = Math.min(sy, ty) + const y2 = Math.max(sy, ty) + // 1d line intersection to check if line crosses block at all, this is + // an optimization that allows us to skip drawing most tree links + // outside the block + if (offsetY + blockSize >= y1 && y2 >= offsetY) { + ctx.beginPath() + ctx.moveTo(sx, sy) + ctx.lineTo(sx, ty) + ctx.lineTo(tx, ty) + ctx.stroke() + } + } +} + +export function renderNodeBubbles({ + ctx, + clickMap, + offsetY, + model, +}: { + ctx: CanvasRenderingContext2D + clickMap: RBush + offsetY: number + model: MsaViewModel +}) { + const { hierarchy, showBranchLen, collapsed, blockSize } = model + for (const node of hierarchy.descendants()) { + const val = showBranchLen ? 'len' : 'y' + const { + // @ts-expect-error + x: y, + // @ts-expect-error + [val]: x, + data, + } = node + const { id = '', name = '' } = data + + if (y > offsetY - extendBounds && y < offsetY + blockSize + extendBounds) { + ctx.strokeStyle = 'black' + ctx.fillStyle = collapsed.includes(id) ? 'black' : 'white' + ctx.beginPath() + ctx.arc(x, y, radius, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + + clickMap.insert({ + minX: x - radius, + maxX: x - radius + d, + minY: y - radius, + maxY: y - radius + d, + branch: true, + id, + name, + }) + } + } +} + +export function renderTreeLabels({ + model, + offsetY, + ctx, + clickMap, +}: { + model: MsaViewModel + offsetY: number + ctx: CanvasRenderingContext2D + clickMap: RBush +}) { + const { + rowHeight, + showBranchLen, + treeMetadata, + hierarchy, + blockSize, + labelsAlignRight, + drawTree, + structures, + treeAreaWidth, + margin, + noTree, + } = model + if (labelsAlignRight) { + ctx.textAlign = 'right' + ctx.setLineDash([1, 3]) + } else { + ctx.textAlign = 'start' + } + for (const node of hierarchy.leaves()) { + const { + // @ts-expect-error + x: y, + // @ts-expect-error + y: x, + data: { name, id }, + // @ts-expect-error + len, + } = node + + const displayName = treeMetadata[name]?.genome || name + + if (y > offsetY - extendBounds && y < offsetY + blockSize + extendBounds) { + // note: +rowHeight/4 matches with -rowHeight/4 in msa + const yp = y + rowHeight / 4 + const xp = showBranchLen ? len : x + + const { width } = ctx.measureText(displayName) + const height = ctx.measureText('M').width // use an 'em' for height + + const hasStructure = structures[name] + ctx.fillStyle = hasStructure ? 'blue' : 'black' + + if (!drawTree && !labelsAlignRight) { + ctx.fillText(displayName, 0, yp) + clickMap.insert({ + minX: 0, + maxX: width, + minY: yp - height, + maxY: yp, + name, + id, + }) + } else if (labelsAlignRight) { + const smallPadding = 2 + const offset = treeAreaWidth - smallPadding - margin.left + if (drawTree && !noTree) { + const { width } = ctx.measureText(displayName) + ctx.moveTo(xp + radius + 2, y) + ctx.lineTo(offset - smallPadding - width, y) + ctx.stroke() + } + ctx.fillText(displayName, offset, yp) + clickMap.insert({ + minX: treeAreaWidth - margin.left - width, + maxX: treeAreaWidth - margin.left, + minY: yp - height, + maxY: yp, + name, + id, + }) + } else { + ctx.fillText(displayName, xp + d, yp) + clickMap.insert({ + minX: xp + d, + maxX: xp + d + width, + minY: yp - height, + maxY: yp, + name, + id, + }) + } + } + } + ctx.setLineDash([]) +} diff --git a/yarn.lock b/yarn.lock index b43ffd43..73a59acc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -935,16 +935,16 @@ pretty-format "^27.0.2" "@testing-library/jest-dom@^6.1.3": - version "6.1.6" - resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.1.6.tgz#d9a3ce61cd74ea792622d3da78a830f6786e8d93" - integrity sha512-YwuiOdYEcxhfC2u5iNKlvg2Q5MgbutovP6drq7J1HrCbvR+G58BbtoCoq+L/kNlrNFsu2Kt3jaFAviLVxYHJZg== + version "6.2.0" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.2.0.tgz#b572bd5cd6b29314487bac7ba393188e4987b4f7" + integrity sha512-+BVQlJ9cmEn5RDMUS8c2+TU6giLvzaHZ8sU/x0Jj7fk+6/46wPdwlgOPcpxS17CjcanBi/3VmGMqVr2rmbUmNw== dependencies: "@adobe/css-tools" "^4.3.2" "@babel/runtime" "^7.9.2" aria-query "^5.0.0" chalk "^3.0.0" css.escape "^1.5.1" - dom-accessibility-api "^0.5.6" + dom-accessibility-api "^0.6.3" lodash "^4.17.15" redent "^3.0.0" @@ -1737,9 +1737,9 @@ callsites@^3.0.0: integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== caniuse-lite@^1.0.30001565: - version "1.0.30001572" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz#1ccf7dc92d2ee2f92ed3a54e11b7b4a3041acfa0" - integrity sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw== + version "1.0.30001574" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz#fb4f1359c77f6af942510493672e1ec7ec80230c" + integrity sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg== canvas-sequencer@^3.1.0: version "3.1.0" @@ -2035,11 +2035,16 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: +dom-accessibility-api@^0.5.9: version "0.5.16" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -2049,14 +2054,14 @@ dom-helpers@^5.0.1: csstype "^3.0.2" dompurify@^3.0.0: - version "3.0.6" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.6.tgz#925ebd576d54a9531b5d76f0a5bef32548351dae" - integrity sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w== + version "3.0.7" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.7.tgz#de8cab085ec28388b95ebf588244ab5f28096e1a" + integrity sha512-BViYTZoqP3ak/ULKOc101y+CtHDUvBsVgSxIF1ku0HmK6BRf+C03MC+tArMvOPtVtZp83DDh5puywKDu4sbVjQ== electron-to-chromium@^1.4.601: - version "1.4.617" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.617.tgz#3b0dde6c54e5f0f26db75ce6c6ae751e5df4bf75" - integrity sha512-sYNE3QxcDS4ANW1k4S/wWYMXjCVcFSOX3Bg8jpuMFaXt/x8JCmp0R1Xe1ZXDX4WXnSRBf+GJ/3eGWicUuQq5cg== + version "1.4.622" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.622.tgz#925d8b2264abbcbe264a9a6290d97b9e5a1af205" + integrity sha512-GZ47DEy0Gm2Z8RVG092CkFvX7SdotG57c4YZOe8W8qD4rOmk3plgeNmiLVRHP/Liqj1wRiY3uUUod9vb9hnxZA== email-addresses@^5.0.0: version "5.0.0" @@ -3673,9 +3678,9 @@ pluralize@^8.0.0: integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== postcss@^8.4.32: - version "8.4.32" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9" - integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw== + version "8.4.33" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.33.tgz#1378e859c9f69bf6f638b990a0212f43e2aaa742" + integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg== dependencies: nanoid "^3.3.7" picocolors "^1.0.0"