diff --git a/taxonium_component/package.json b/taxonium_component/package.json index 7e4da8b8..8476199c 100644 --- a/taxonium_component/package.json +++ b/taxonium_component/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@fontsource/roboto": "^5.0.1", + "@headlessui/react": "^1.7.17", "@jbrowse/core": "^2.5.0", "@jbrowse/plugin-data-management": "^2.5.0", "@jbrowse/react-linear-genome-view": "^2.5.0", diff --git a/taxonium_component/src/Deck.jsx b/taxonium_component/src/Deck.jsx index b1dc18b1..a28c83f3 100644 --- a/taxonium_component/src/Deck.jsx +++ b/taxonium_component/src/Deck.jsx @@ -178,7 +178,7 @@ function Deck({ [hoverDetails] ); - const { layers, layerFilter, keyStuff } = useLayers({ + const { layers, layerFilter, keyStuff,triggerSVGdownload } = useLayers({ data, search, viewState, @@ -314,6 +314,7 @@ function Deck({ // we want this to intercept all mouse events // so that we can prevent the default behavior // of the browser + triggerSVGdownload={triggerSVGdownload} zoomReset={zoomReset} zoomIncrement={zoomIncrement} diff --git a/taxonium_component/src/components/DeckButtons.jsx b/taxonium_component/src/components/DeckButtons.jsx index b1fa96cb..ff39bcf2 100644 --- a/taxonium_component/src/components/DeckButtons.jsx +++ b/taxonium_component/src/components/DeckButtons.jsx @@ -11,22 +11,8 @@ import { TiZoom, TiCog } from "react-icons/ti"; import { MdOutlineZoomOutMap } from "react-icons/md"; import { ClipLoader } from "react-spinners"; - -const TaxButton = ({ children, onClick, title }) => { - return ( - - ); -}; +import TaxButton from "./TaxButton"; +import SnapshotButton from "./SnapshotButton"; export const DeckButtons = ({ loading, @@ -37,6 +23,7 @@ export const DeckButtons = ({ requestOpenSettings, zoomReset, settings, + triggerSVGdownload }) => { return (
- { - snapshot(); - }} - title="Take screenshot" - > - - + +
{ + const [isOpen, setIsOpen] = useState(false); + + function snapshot(option) { + if (option === 'pixels') { + pixelFunction(); + } else if (option === 'SVG') { + svgFunction(); + } + setIsOpen(false); + } + + return ( + <> + setIsOpen(true)} + title="Take screenshot" + > + + + + + +
+ + + + +
+ + Choose format + + +
+ + +
+
+
+
+
+ + ); +} + +export default SnapshotButton; \ No newline at end of file diff --git a/taxonium_component/src/components/TaxButton.jsx b/taxonium_component/src/components/TaxButton.jsx new file mode 100644 index 00000000..13dffc48 --- /dev/null +++ b/taxonium_component/src/components/TaxButton.jsx @@ -0,0 +1,18 @@ + +const TaxButton = ({ children, onClick, title }) => { + return ( + + ); + }; + + export default TaxButton; \ No newline at end of file diff --git a/taxonium_component/src/hooks/useLayers.jsx b/taxonium_component/src/hooks/useLayers.jsx index 658c3f3e..971f623f 100644 --- a/taxonium_component/src/hooks/useLayers.jsx +++ b/taxonium_component/src/hooks/useLayers.jsx @@ -8,6 +8,7 @@ import { import { useMemo, useCallback } from "react"; import useTreenomeLayers from "./useTreenomeLayers"; +import getSVGfunction from "../utils/deckglToSvg"; const getKeyStuff = (getNodeColorField, colorByField, dataset, toRGB) => { const counts = {}; @@ -570,10 +571,12 @@ const useLayers = ({ console.log("could not map layer spec for ", layer); }); - console.log("processedLayers", processedLayers) + const {renderSVG, triggerSVGdownload} = getSVGfunction(layers,viewState); + window.renderSVG = renderSVG; + - return { layers: processedLayers, layerFilter, keyStuff }; + return { layers: processedLayers, layerFilter, keyStuff, renderSVG, triggerSVGdownload }; }; export default useLayers; diff --git a/taxonium_component/src/utils/deckglToSvg.js b/taxonium_component/src/utils/deckglToSvg.js new file mode 100644 index 00000000..165b09eb --- /dev/null +++ b/taxonium_component/src/utils/deckglToSvg.js @@ -0,0 +1,179 @@ + + +const getSVGfunction = (layers, viewState) => { + +const accessOrConstant = (accessor, node) => { + if (typeof accessor === "function") { + return accessor(node); + } else { + return accessor; + } + }; + const normalise = (value,min,max)=>{ + + return (value-min)/(max-min) + } + + + const svgHeight = 600 + const svgWidth = 600 + + + const applyBounds = ( point) => { + + + const minY = viewState.min_y + const maxY = viewState.max_y + const minX = viewState.min_x + const maxX = viewState.max_x + const initial = point + const x = normalise(initial[0],minX,maxX) + const y = normalise(initial[1],minY,maxY) + return [x*svgWidth,y*svgHeight] + + }; + + + const getSVG = (layers) => { + if(!viewState.min_x){ + window.alert("Please zoom in and out a little before SVG export") + return + } + let svgContent = ""; + + for (const layer of layers) { + // unless layer id starts with "main" + if (!layer.id.startsWith("main")) { + continue; + } + + switch (layer.layerType) { + + case "ScatterplotLayer": + for (const point of layer.data) { + const [x, y] = applyBounds(layer.getPosition(point)); + // if either is null, skip this point + if (x === null || y === null) { + continue; + } + const accessor = layer.getFillColor ? layer.getFillColor : layer.getColor; + let color + if (!accessor) { + // make color transparent + color = [0,0,0,0] + } else { + color = accessOrConstant(accessor, point).join(","); + } + // check if stroked + let strokeColor, strokeWidth + if (layer.stroked) { + strokeColor = accessOrConstant(layer.getLineColor, point).join(","); + strokeWidth = accessOrConstant(layer.getLineWidth, point); + } + + + + + // if getRadius is a fn call it otherwise assume it's a value + const radius = accessOrConstant(layer.getRadius, point); + svgContent += ``; + } + break; + + case "LineLayer": + for (const line of layer.data) { + const [x1, y1] = applyBounds( layer.getSourcePosition(line)); + const [x2, y2] = applyBounds(layer.getTargetPosition(line)); + // if either is null, skip this point + if (x1 === null || y1 === null || x2 === null || y2 === null) { + continue; + } + const colorAccessor = layer.getLineColor ? layer.getLineColor : layer.getColor; + // if colorAccessor is a function, call it with the line as an argument, otherwise assume it's an array + const color = accessOrConstant(colorAccessor, line).join(","); + const width = accessOrConstant(layer.getWidth, line); + svgContent += ``; + } + break; + + case "TextLayer": + + for (const text of layer.data) { + //const [x, y] = applyModelMatrix(layer.modelMatrix, layer.getPosition(text)); + const original = layer.getPosition(text) + const adjusted = applyBounds( original) + + const [x, y] = adjusted; + const size = accessOrConstant(layer.getSize, text); + const alignment = accessOrConstant(layer.getAlignmentBaseline, text); + const anchor = accessOrConstant(layer.getTextAnchor, text); + const pixelOffset = accessOrConstant(layer.getPixelOffset, text); + + const color = accessOrConstant(layer.getColor, text).join(","); + const newContent = `${layer.getText(text)}`; + svgContent += newContent; + + } + break; + + + + // You can extend this with other layer types such as PolygonLayer, SolidPolygonLayer, etc. + + default: + console.warn(`Unsupported layer type: ${layer.layerType}`); + break; + } + } + + svgContent += ""; + return svgContent; + }; + console.log(viewState) + function renderSVG() { + const svgContent = getSVG(layers, viewState); + // Create a new SVG container element + const svgContainer = document.createElement("div"); + svgContainer.id = "mySVG"; + svgContainer.innerHTML = svgContent; + + // Optional: set styles for the SVG container + svgContainer.style.width = '100%'; // or any other desired width + svgContainer.style.height = 'auto'; // maintains aspect ratio + + // if there's already an SVG container, remove it + const existingSVG = document.getElementById("mySVG"); + if (existingSVG) { + existingSVG.remove(); + } + // Append the container to the bottom of the body + document.body.appendChild(svgContainer); + } + + function triggerSVGdownload() { + const svgContent = getSVG(layers, viewState); + // Create a new blob object + const blob = new Blob([svgContent], { type: "image/svg+xml" }); + // Create a link element, hide it, direct it towards the blob, and then 'click' it programatically + const a = document.createElement("a"); + a.style.display = "none"; + document.body.appendChild(a); + // Create a DOMString representing the blob and point the link element towards it + const url = window.URL.createObjectURL(blob); + a.href = url; + const date_and_time = new Date().toISOString().replace(/:/g, "-"); + a.download = `taxonium-${date_and_time}.svg`; + //programatically click the link to trigger the download + a.click(); + //release the reference to the file by revoking the Object URL + window.URL.revokeObjectURL(url); + } + + + return {renderSVG, triggerSVGdownload} +} + +export default getSVGfunction \ No newline at end of file diff --git a/taxonium_component/yarn.lock b/taxonium_component/yarn.lock index f3e712d0..7ca541ad 100644 --- a/taxonium_component/yarn.lock +++ b/taxonium_component/yarn.lock @@ -1529,6 +1529,13 @@ resolved "https://registry.yarnpkg.com/@gmod/vcf/-/vcf-5.0.10.tgz#6c2d7952b15f61642454be90119ea89fd3c227de" integrity sha512-o7QuPcOeXlJpzwQaFmgojhNvJE4yB9fhrfVEDKpkDjV27pAqwMy89367vtXu4JfBFE9t4zZ6sQRkqYaJ+cIheg== +"@headlessui/react@^1.7.17": + version "1.7.17" + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.17.tgz#a0ec23af21b527c030967245fd99776aa7352bc6" + integrity sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow== + dependencies: + client-only "^0.0.1" + "@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -4659,6 +4666,11 @@ cli-width@^4.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.0.0.tgz#a5622f6a3b0a9e3e711a25f099bf2399f608caf6" integrity sha512-ZksGS2xpa/bYkNzN3BAw1wEjsLV/ZKOf/CCrJ/QOBsxx6fOARIkwTutxp1XIOIohi6HKmOFjMoK/XaqDVUpEEw== +client-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"