Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Unitary Hack] Save Python qsharp-widgets RE output to PNG #1604

Merged
merged 15 commits into from
Jun 14, 2024
Merged
2 changes: 2 additions & 0 deletions npm/qsharp/ux/estimatesOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export function EstimatesOverview(props: {
isSimplifiedView: boolean;
onRowDeleted: (rowId: string) => void;
setEstimate: (estimate: SingleEstimateResult | null) => void;
allowSaveImage: boolean;
}) {
const [selectedRow, setSelectedRow] = useState<string | null>(null);
const [selectedPoint, setSelectedPoint] = useState<[number, number]>();
Expand Down Expand Up @@ -277,6 +278,7 @@ export function EstimatesOverview(props: {
)}
onPointSelected={onPointSelected}
selectedPoint={selectedPoint}
allowSaveImage={props.allowSaveImage}
/>
);
}
Expand Down
2 changes: 2 additions & 0 deletions npm/qsharp/ux/estimatesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function EstimatesPanel(props: {
runNames: string[];
calculating: boolean;
onRowDeleted: (rowId: string) => void;
allowSaveImage?: boolean;
}) {
const [estimate, setEstimate] = useState<SingleEstimateResult | null>(null);

Expand Down Expand Up @@ -63,6 +64,7 @@ export function EstimatesPanel(props: {
setEstimate={setEstimate}
runNames={props.runNames}
colors={props.colors}
allowSaveImage={props.allowSaveImage || false}
></EstimatesOverview>
{!estimate ? null : (
<>
Expand Down
17 changes: 17 additions & 0 deletions npm/qsharp/ux/qsharp-ux.css
Original file line number Diff line number Diff line change
Expand Up @@ -711,3 +711,20 @@ html {
.qs-estimatesOverview-error {
color: red;
}

.qs-estimatesOverview-saveIcon {
cursor: pointer;
width: 3em;
height: 3em;
background-color: var(--main-background);
position: absolute;
bottom: 5px;
left: 5px;
border: none;
}

.qs-estimatesOverview-saveIconSvgPath {
fill: var(--main-color) !important;
fill-rule: evenodd !important;
clip-rule: evenodd !important;
}
107 changes: 107 additions & 0 deletions npm/qsharp/ux/saveImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// Writes resource estimator output to PNG file

import {
getImageSize,
createImage,
nodeToDataURI,
toArray,
} from "./saveImageUtil.js";

async function cloneSingleNode<T extends HTMLElement>(
node: T,
): Promise<HTMLElement> {
return node.cloneNode(false) as T;
}

async function cloneChildren<T extends HTMLElement>(
nativeNode: T,
clonedNode: T,
): Promise<T> {
let children: T[] = [];
children = toArray<T>((nativeNode.shadowRoot ?? nativeNode).childNodes);

// Depth-first traversal of DOM objects
await children.reduce(
(deferred, child) =>
deferred
.then(() => cloneNode(child))
.then((clonedChild: HTMLElement | null) => {
if (clonedChild) {
clonedNode.appendChild(clonedChild);
}
}),
Promise.resolve(),
);

return clonedNode;
}

function cloneCSSStyle<T extends HTMLElement>(nativeNode: T, clonedNode: T) {
const targetStyle = clonedNode.style;
if (!targetStyle) {
return;
}

const sourceStyle = window.getComputedStyle(nativeNode);
toArray<string>(sourceStyle).forEach((name) => {
const value = sourceStyle.getPropertyValue(name);
targetStyle.setProperty(name, value, sourceStyle.getPropertyPriority(name));
});
}

function decorate<T extends HTMLElement>(nativeNode: T, clonedNode: T): T {
cloneCSSStyle(nativeNode, clonedNode);
return clonedNode;
}

async function cloneNode<T extends HTMLElement>(node: T): Promise<T | null> {
return Promise.resolve(node)
.then((clonedNode) => cloneSingleNode(clonedNode) as Promise<T>)
.then((clonedNode) => cloneChildren(node, clonedNode))
.then((clonedNode) => decorate(node, clonedNode));
}

async function saveToPng<T extends HTMLElement>(
node: T,
backgroundColor: string,
): Promise<string> {
const { width, height } = getImageSize(node);
const clonedNode = (await cloneNode(node)) as HTMLElement;
const uri = await nodeToDataURI(clonedNode, width, height);
const img = await createImage(uri);

const ratio = window.devicePixelRatio || 1;
const canvas = document.createElement("canvas");
canvas.width = width * ratio;
canvas.height = height * ratio;

const context = canvas.getContext("2d")!;
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);

context.drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL();
}

export async function saveToImage<T extends HTMLElement>(
element: T,
filename = "image.png",
) {
const backgroundColor =
getComputedStyle(element).getPropertyValue("--main-background");
const data = await saveToPng(element, backgroundColor);
const link = document.createElement("a");
if (typeof link.download === "string") {
link.href = data;
link.download = filename;

document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
window.open(data);
}
}
72 changes: 72 additions & 0 deletions npm/qsharp/ux/saveImageUtil.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

export function toArray<T>(arrayLike: any): T[] {
const arr: T[] = [];
for (let i = 0, l = arrayLike.length; i < l; i++) {
arr.push(arrayLike[i]);
}

return arr;
}

function px<T extends HTMLElement>(node: T, styleProperty: string) {
const win = node.ownerDocument.defaultView || window;
const val = win.getComputedStyle(node).getPropertyValue(styleProperty);
return val ? parseFloat(val.replace("px", "")) : 0;
}

export function getImageSize<T extends HTMLElement>(node: T) {
const leftBorder = px(node, "border-left-width");
const rightBorder = px(node, "border-right-width");
const topBorder = px(node, "border-top-width");
const bottomBorder = px(node, "border-bottom-width");

return {
width: node.clientWidth + leftBorder + rightBorder,
height: node.clientHeight + topBorder + bottomBorder + 12, // Fixes up truncated region
};
}

export function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.decode = () => resolve(img) as any;
img.onload = () => resolve(img);
img.onerror = reject;
img.crossOrigin = "anonymous";
img.decoding = "async";
img.src = url;
});
}

export async function svgToDataURI(svg: Element): Promise<string> {
return Promise.resolve()
.then(() => new XMLSerializer().serializeToString(svg))
.then(encodeURIComponent)
.then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
}

export async function nodeToDataURI(
node: HTMLElement,
width: number,
height: number,
): Promise<string> {
const xmlns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(xmlns, "svg");
const foreignObject = document.createElementNS(xmlns, "foreignObject");

svg.setAttribute("width", `${width}`);
svg.setAttribute("height", `${height}`);
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);

foreignObject.setAttribute("width", "100%");
foreignObject.setAttribute("height", "100%");
foreignObject.setAttribute("x", "0");
foreignObject.setAttribute("y", "0");
foreignObject.setAttribute("externalResourcesRequired", "true");

svg.appendChild(foreignObject);
foreignObject.appendChild(node);
return svgToDataURI(svg);
}
31 changes: 31 additions & 0 deletions npm/qsharp/ux/scatterChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// Licensed under the MIT License.

import { useRef, useEffect } from "preact/hooks";
import { createRef } from "preact";
import * as utils from "../src/utils.js";
import { saveToImage } from "./saveImage.js";

export type ScatterSeries = {
color: string;
Expand Down Expand Up @@ -31,6 +33,7 @@ export function ScatterChart(props: {
yAxis: Axis;
onPointSelected(seriesIndex: number, pointIndex: number): void;
selectedPoint?: [number, number];
allowSaveImage: boolean;
}) {
const selectedTooltipDiv = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -154,6 +157,12 @@ export function ScatterChart(props: {
}
const selectedPoint = getSelectedPointData();

const saveRef = createRef();

const handleSaveImage = async () => {
saveToImage(saveRef!.current);
};

// Need to render first to get the element layout to position the tooltip
useEffect(() => {
if (!selectedTooltipDiv.current) return;
Expand Down Expand Up @@ -182,6 +191,7 @@ export function ScatterChart(props: {
onMouseOver={(ev) => onPointMouseEvent(ev, "over")}
onMouseOut={(ev) => onPointMouseEvent(ev, "out")}
onClick={(ev) => onPointMouseEvent(ev, "click")}
ref={saveRef}
>
<line
class="qs-scatterChart-axis"
Expand Down Expand Up @@ -316,6 +326,27 @@ export function ScatterChart(props: {
</svg>
<div class="qs-scatterChart-selectedInfo" ref={selectedTooltipDiv}></div>
<div class="qs-scatterChart-tooltip"></div>
{props.allowSaveImage ? (
<button
role="button"
onClick={handleSaveImage}
className={"qs-estimatesOverview-saveIcon"}
>
<span>
<svg
width="75%"
height="75%"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
className={"qs-estimatesOverview-saveIconSvgPath"}
d="M12.0147 2.8595L13.1397 3.9845L13.25 4.25V12.875L12.875 13.25H3.125L2.75 12.875V3.125L3.125 2.75H11.75L12.0147 2.8595ZM3.5 3.5V12.5H12.5V4.406L11.5947 3.5H10.25V6.5H5V3.5H3.5ZM8 3.5V5.75H9.5V3.5H8Z"
/>
</svg>
</span>
</button>
) : null}
</div>
);
}
2 changes: 2 additions & 0 deletions widgets/js/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ function renderEstimatesOverview({ model, el }: RenderArgs) {
isSimplifiedView={true}
onRowDeleted={onRowDeleted}
setEstimate={() => undefined}
allowSaveImage={true}
></EstimatesOverview>,
el,
);
Expand Down Expand Up @@ -161,6 +162,7 @@ function renderEstimatesPanel({ model, el }: RenderArgs) {
renderer={mdRenderer}
calculating={false}
onRowDeleted={onRowDeleted}
allowSaveImage={true}
></EstimatesPanel>,
el,
);
Expand Down
Loading