From 21c8fc40843f547431988955ff76c58bbbc0417a Mon Sep 17 00:00:00 2001 From: Ethan McElroy Date: Thu, 9 Jan 2025 13:00:53 -0500 Subject: [PATCH 1/2] feat: export graph image snapshot (PT-188650872) (#1692) * WIP: export graph image * chore: add data display handler * refactor: improve data display handler definition, registration * chore: improve image utils * chore: make diDataDisplayHandler async * chore: use CFM for image export/download * chore: improve test coverage * chore: minor refactorfor image-utils test * chore: more test coverage, clean up * chore: code review suggestions * fix: add callback function to saveSecondaryFileAsDialog call * chore: more review suggestions * chore: remove unused import Co-authored-by: lublagg --- v3/cypress.config.ts | 15 +- v3/cypress/e2e/graph.spec.ts | 20 ++ v3/src/components/app.tsx | 13 +- .../components/legend/categorical-legend.tsx | 2 +- .../components/legend/color-legend.tsx | 2 +- .../components/legend/numeric-legend.tsx | 4 +- .../models/data-display-content-model.ts | 5 + .../models/data-display-render-state.ts | 47 ++++ .../graph/components/camera-menu-list.tsx | 19 +- .../graph/components/graph-component.tsx | 16 +- v3/src/components/graph/components/graph.tsx | 16 +- .../graph/graph-data-display-handler.test.ts | 23 ++ .../graph/graph-data-display-handler.ts | 16 ++ v3/src/components/graph/graph-registration.ts | 3 + .../graph/utilities/image-utils.test.ts | 89 ++++++ .../components/graph/utilities/image-utils.ts | 255 ++++++++++++++++++ .../data-interactive-types.ts | 9 +- .../handlers/data-display-handler.ts | 42 +++ .../data-interactive/handlers/di-results.ts | 1 + v3/src/data-interactive/register-handlers.ts | 1 + v3/src/data-interactive/resource-parser.ts | 7 +- v3/src/hooks/use-cfm-context.ts | 8 + v3/src/utilities/translation/lang/en-US.json5 | 1 + 23 files changed, 593 insertions(+), 21 deletions(-) create mode 100644 v3/src/components/data-display/models/data-display-render-state.ts create mode 100644 v3/src/components/graph/graph-data-display-handler.test.ts create mode 100644 v3/src/components/graph/graph-data-display-handler.ts create mode 100644 v3/src/components/graph/utilities/image-utils.test.ts create mode 100644 v3/src/components/graph/utilities/image-utils.ts create mode 100644 v3/src/data-interactive/handlers/data-display-handler.ts create mode 100644 v3/src/hooks/use-cfm-context.ts diff --git a/v3/cypress.config.ts b/v3/cypress.config.ts index b6eb218fe2..047e7cc155 100644 --- a/v3/cypress.config.ts +++ b/v3/cypress.config.ts @@ -22,13 +22,26 @@ export default defineConfig({ // We've imported your old cypress plugins here. // You may want to clean this up later by importing these. setupNodeEvents(on, config) {// promisified fs module - function getConfigurationByFile(file) { const pathToConfigFile = path.resolve('.', 'cypress/config', `cypress.${file}.json`) return fs.readJson(pathToConfigFile) } + // Tasks for checking, clearing downloaded files. + on("task", { + fileExists(filePath) { + return fs.existsSync(filePath) + } + }) + on("task", { + clearFolder(folderPath) { + fs.rmdirSync(folderPath, { recursive: true }); + fs.mkdirSync(folderPath); + return null; + } + }) + const env = config.env.testEnv || 'local' return getConfigurationByFile(env) diff --git a/v3/cypress/e2e/graph.spec.ts b/v3/cypress/e2e/graph.spec.ts index 8c9bfc61ba..e4a5e3890b 100644 --- a/v3/cypress/e2e/graph.spec.ts +++ b/v3/cypress/e2e/graph.spec.ts @@ -457,6 +457,26 @@ context("Graph UI", () => { cy.get('input[type="checkbox"]').should('be.checked') }) }) + it("leads to a file download when the 'Export PNG Image' button is clicked", () => { + const fileName = "Untitled Document.png" + const downloadsFolder = Cypress.config("downloadsFolder") + graph.getCameraButton().click() + cy.get("[data-testid=export-png-image]").should("be.visible") + cy.get("[data-testid=export-png-image]").click() + cy.get("[data-testid=export-png-image]").should("not.exist") + cy.get("[data-testid=modal-dialog]").should("be.visible") + cy.get("[data-testid=modal-dialog-title]").should("have.text", "Export File As ...") + cy.get("[data-testid=modal-dialog-workspace]").find("li").contains("Local File").click() + cy.get("[data-testid=modal-dialog-workspace]").find(".buttons a[download]").contains("Download") + .should("be.visible").click() + cy.get("[data-testid=modal-dialog]").should("not.exist") + + cy.task("fileExists", `${downloadsFolder}/${fileName}`).then((exists) => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(exists).to.be.true + cy.task("clearFolder", downloadsFolder) + }) + }) }) describe("graph bin configuration", () => { it("disables Point Size control when display type is bars", () => { diff --git a/v3/src/components/app.tsx b/v3/src/components/app.tsx index c79a29113a..b5a978c0b4 100644 --- a/v3/src/components/app.tsx +++ b/v3/src/components/app.tsx @@ -24,6 +24,7 @@ import { urlParams } from "../utilities/url-params" import { kWebViewTileType } from "./web-view/web-view-defs" import { isWebViewModel } from "./web-view/web-view-model" import { logStringifiedObjectMessage } from "../lib/log-message" +import { CfmContext } from "../hooks/use-cfm-context" import "../models/shared/shared-case-metadata-registration" import "../models/shared/shared-data-set-registration" @@ -123,11 +124,13 @@ export const App = observer(function App() { return ( -
- - - -
+ +
+ + + +
+
) diff --git a/v3/src/components/data-display/components/legend/categorical-legend.tsx b/v3/src/components/data-display/components/legend/categorical-legend.tsx index 2ccc3f0fc1..1c1334d106 100644 --- a/v3/src/components/data-display/components/legend/categorical-legend.tsx +++ b/v3/src/components/data-display/components/legend/categorical-legend.tsx @@ -349,7 +349,7 @@ export const CategoricalLegend = observer( }, [setDesiredExtent, layerIndex]) return ( - + ) }) CategoricalLegend.displayName = "CategoricalLegend" diff --git a/v3/src/components/data-display/components/legend/color-legend.tsx b/v3/src/components/data-display/components/legend/color-legend.tsx index 61415a8125..3e521b648a 100644 --- a/v3/src/components/data-display/components/legend/color-legend.tsx +++ b/v3/src/components/data-display/components/legend/color-legend.tsx @@ -44,5 +44,5 @@ export const ColorLegend = observer(function ColorLegend({layerIndex, setDesired return () => setDesiredExtent(layerIndex, 0) }, [setDesiredExtent, layerIndex]) - return + return }) diff --git a/v3/src/components/data-display/components/legend/numeric-legend.tsx b/v3/src/components/data-display/components/legend/numeric-legend.tsx index 94f00fd365..77fe49c4d6 100644 --- a/v3/src/components/data-display/components/legend/numeric-legend.tsx +++ b/v3/src/components/data-display/components/legend/numeric-legend.tsx @@ -139,6 +139,6 @@ export const NumericLegend = } }, [setDesiredExtent, layerIndex]) - return setChoroplethElt(elt)} data-testid='legend-categories'> - + return setChoroplethElt(elt)} data-testid='legend-categories'> + }) diff --git a/v3/src/components/data-display/models/data-display-content-model.ts b/v3/src/components/data-display/models/data-display-content-model.ts index 25b3919dcd..8f2e30d0d6 100644 --- a/v3/src/components/data-display/models/data-display-content-model.ts +++ b/v3/src/components/data-display/models/data-display-content-model.ts @@ -22,6 +22,7 @@ import { IGetTipTextProps } from "../data-tip-types" import { IDataConfigurationModel } from "./data-configuration-model" import {DataDisplayLayerModelUnion} from "./data-display-layer-union" import {DisplayItemDescriptionModel} from "./display-item-description-model" +import { DataDisplayRenderState } from "./data-display-render-state" export const DataDisplayContentModel = TileContentModel .named("DataDisplayContentModel") @@ -37,6 +38,7 @@ export const DataDisplayContentModel = TileContentModel .volatile(() => ({ animationTimerId: 0, marqueeMode: 'unclicked' as MarqueeMode, + renderState: undefined as DataDisplayRenderState | undefined, })) .views(self => ({ placeCanAcceptAttributeIDDrop(place: GraphPlace, @@ -144,6 +146,9 @@ export const DataDisplayContentModel = TileContentModel }, setMarqueeMode(mode: MarqueeMode) { self.marqueeMode = mode + }, + setRenderState(renderState: DataDisplayRenderState) { + self.renderState = renderState } })) export interface IDataDisplayContentModel extends Instance {} diff --git a/v3/src/components/data-display/models/data-display-render-state.ts b/v3/src/components/data-display/models/data-display-render-state.ts new file mode 100644 index 0000000000..5a8686f49a --- /dev/null +++ b/v3/src/components/data-display/models/data-display-render-state.ts @@ -0,0 +1,47 @@ +import { graphSnapshot } from "../../graph/utilities/image-utils" +import { PixiPointsArray } from "../pixi/pixi-points" + +export class DataDisplayRenderState { + pixiPointsArray: PixiPointsArray + displayElement: HTMLElement + getTitle?: (() => string | undefined) + dataUri?: string + + constructor( + pixiPointsArray: PixiPointsArray, + displayElement: HTMLElement, + getTitle?: () => string, + dataUri?: string + ) { + this.pixiPointsArray = pixiPointsArray + this.displayElement = displayElement + this.getTitle = getTitle + this.dataUri = dataUri + } + + setDataUri(dataUri: string) { + this.dataUri = dataUri + } + + async updateSnapshot() { + const title = this.getTitle?.() || "" + const pixiPoints = this.pixiPointsArray?.[0] + if (!this.displayElement || !pixiPoints) return + + const width = this.displayElement.getBoundingClientRect().width ?? 0 + const height = this.displayElement.getBoundingClientRect().height ?? 0 + const svgElementsToImageOptions = { + rootEl: this.displayElement, + graphWidth: width, + graphHeight: height, + graphTitle: title, + asDataURL: true, + pixiPoints + } + const graphImage = await graphSnapshot(svgElementsToImageOptions) + const dataUri = typeof graphImage === "string" ? graphImage : undefined + if (dataUri) { + this.setDataUri(dataUri) + } + } +} diff --git a/v3/src/components/graph/components/camera-menu-list.tsx b/v3/src/components/graph/components/camera-menu-list.tsx index 639154bed1..7c88eb6382 100644 --- a/v3/src/components/graph/components/camera-menu-list.tsx +++ b/v3/src/components/graph/components/camera-menu-list.tsx @@ -1,8 +1,14 @@ import React, { useState } from "react" import { MenuItem, MenuList, useToast } from "@chakra-ui/react" import { t } from "../../../utilities/translation/translate" +import { useTileModelContext } from "../../../hooks/use-tile-model-context" +import { isGraphContentModel } from "../models/graph-content-model" +import { useCfmContext } from "../../../hooks/use-cfm-context" export const CameraMenuList = () => { + const tile = useTileModelContext().tile + const graphModel = isGraphContentModel(tile?.content) ? tile?.content : undefined + const cfm = useCfmContext() const [hasBackgroundImage, setHasBackgroundImage] = useState(false) const [imageLocked, setImageLocked] = useState(false) const toast = useToast() @@ -40,8 +46,17 @@ export const CameraMenuList = () => { handleMenuItemClick("Copy Image clicked") } - const handleExportPNG = () => { - handleMenuItemClick("Export PNG Image clicked") + const handleExportPNG = async () => { + if (!graphModel?.renderState) return + + await graphModel.renderState.updateSnapshot() + + if (graphModel.renderState.dataUri) { + const imageString = graphModel.renderState.dataUri.replace("data:image/png;base64,", "") + cfm?.client.saveSecondaryFileAsDialog(imageString, "png", "image/png", () => null) + } else { + console.error("Error exporting PNG image.") + } } const handleExportSVG = () => { diff --git a/v3/src/components/graph/components/graph-component.tsx b/v3/src/components/graph/components/graph-component.tsx index bfafdbe16f..8b1b84d7b2 100644 --- a/v3/src/components/graph/components/graph-component.tsx +++ b/v3/src/components/graph/components/graph-component.tsx @@ -24,12 +24,14 @@ import { graphCollisionDetection, kGraphIdBase } from './graph-drag-drop' import { kTitleBarHeight } from "../../constants" import {AttributeDragOverlay} from "../../drag-drop/attribute-drag-overlay" import "../register-adornment-types" +import { getTitle } from '../../../models/tiles/tile-content-info' +import { DataDisplayRenderState } from '../../data-display/models/data-display-render-state' registerTileCollisionDetection(kGraphIdBase, graphCollisionDetection) export const GraphComponent = observer(function GraphComponent({tile}: ITileBaseProps) { const graphModel = isGraphContentModel(tile?.content) ? tile?.content : undefined - + const title = (tile && getTitle?.(tile)) || tile?.title || "" const instanceId = useNextInstanceId("graph") const {data} = useDataSet(graphModel?.dataset) const layout = useInitGraphLayout(graphModel) @@ -49,6 +51,16 @@ export const GraphComponent = observer(function GraphComponent({tile}: ITileBase useGraphController({graphController, graphModel, pixiPointsArray}) + const setGraphRef = (ref: HTMLDivElement | null) => { + graphRef.current = ref + const elementParent = ref?.parentElement + const dataUri = graphModel?.renderState?.dataUri ?? undefined + if (elementParent) { + const renderState = new DataDisplayRenderState(pixiPointsArray, elementParent, () => title, dataUri) + graphModel?.setRenderState(renderState) + } + } + useEffect(() => { (width != null) && width >= 0 && (height != null) && height >= kTitleBarHeight && layout.setTileExtent(width, height) @@ -78,7 +90,7 @@ export const GraphComponent = observer(function GraphComponent({tile}: ITileBase diff --git a/v3/src/components/graph/components/graph.tsx b/v3/src/components/graph/components/graph.tsx index 09d55edb34..e63f6f614f 100644 --- a/v3/src/components/graph/components/graph.tsx +++ b/v3/src/components/graph/components/graph.tsx @@ -1,7 +1,7 @@ import {comparer} from "mobx" import {observer} from "mobx-react-lite" import {IDisposer, isAlive} from "mobx-state-tree" -import React, {MutableRefObject, useCallback, useEffect, useMemo, useRef} from "react" +import React, {useCallback, useEffect, useMemo, useRef} from "react" import {select} from "d3" import {clsx} from "clsx" import { logStringifiedObjectMessage } from "../../../lib/log-message" @@ -45,11 +45,11 @@ import "./graph.scss" interface IProps { graphController: GraphController - graphRef: MutableRefObject + setGraphRef: (ref: HTMLDivElement | null) => void pixiPointsArray: PixiPointsArray } -export const Graph = observer(function Graph({graphController, graphRef, pixiPointsArray}: IProps) { +export const Graph = observer(function Graph({graphController, setGraphRef, pixiPointsArray}: IProps) { const graphModel = useGraphContentModelContext(), {plotType} = graphModel, pixiPoints = pixiPointsArray[0], @@ -65,7 +65,8 @@ export const Graph = observer(function Graph({graphController, graphRef, pixiPoi pixiContainerRef = useRef(null), prevAttrCollectionsMapRef = useRef>({}), xAttrID = graphModel.getAttributeID('x'), - yAttrID = graphModel.getAttributeID('y') + yAttrID = graphModel.getAttributeID('y'), + graphRef = useRef(null) if (pixiPoints?.canvas && pixiContainerRef.current && pixiContainerRef.current.children.length === 0) { pixiContainerRef.current.appendChild(pixiPoints.canvas) @@ -74,6 +75,11 @@ export const Graph = observer(function Graph({graphController, graphRef, pixiPoi }) } + const mySetGraphRef = (ref: HTMLDivElement | null) => { + graphRef.current = ref + setGraphRef(ref) + } + useEffect(function handleFilteredCasesChange() { return mstReaction( () => graphModel.dataConfiguration.filteredCases.map(({ id }) => id), @@ -358,7 +364,7 @@ export const Graph = observer(function Graph({graphController, graphRef, pixiPoi return ( -
+
{graphModel.showParentToggles && } { + it("should return an exportDataUri for a graph", async () => { + const graphContent = { + renderState: { + updateSnapshot: jest.fn(), + dataUri: "data:image/png;base64," + }, + type: kGraphTileType + } as Partial + const result = await graphDataDisplayHandler.get(graphContent as ITileContentModel) + expect(result).toEqual({ exportDataUri: "data:image/png;base64,", success: true }) + }) + + it("should return success as false for other types", async () => { + const content = { type: "table" } as ITileContentModel + const result = await graphDataDisplayHandler.get(content) + expect(result).toEqual({ success: false }) + }) +}) diff --git a/v3/src/components/graph/graph-data-display-handler.ts b/v3/src/components/graph/graph-data-display-handler.ts new file mode 100644 index 0000000000..ea3d5dd55d --- /dev/null +++ b/v3/src/components/graph/graph-data-display-handler.ts @@ -0,0 +1,16 @@ +import { DIDataDisplayHandler } from "../../data-interactive/handlers/data-display-handler" +import { ITileContentModel } from "../../models/tiles/tile-content" +import { isGraphContentModel } from "./models/graph-content-model" + +export const graphDataDisplayHandler: DIDataDisplayHandler = { + async get(content: ITileContentModel) { + + if (isGraphContentModel(content)) { + await content?.renderState?.updateSnapshot() + const exportDataUri = content?.renderState?.dataUri + return { exportDataUri, success: !!exportDataUri } + } else { + return { success: false } + } + } +} diff --git a/v3/src/components/graph/graph-registration.ts b/v3/src/components/graph/graph-registration.ts index dceef4d054..8219791e1e 100644 --- a/v3/src/components/graph/graph-registration.ts +++ b/v3/src/components/graph/graph-registration.ts @@ -21,6 +21,8 @@ import { kGraphDataConfigurationType } from "./models/graph-data-configuration-m import { GraphFilterFormulaAdapter } from "./models/graph-filter-formula-adapter" import { kGraphPointLayerType } from "./models/graph-point-layer-model" import { v2GraphImporter } from "./v2-graph-importer" +import { registerDataDisplayHandler } from "../../data-interactive/handlers/data-display-handler" +import { graphDataDisplayHandler } from "./graph-data-display-handler" GraphFilterFormulaAdapter.register() @@ -79,3 +81,4 @@ registerTileComponentInfo({ registerV2TileImporter("DG.GraphView", v2GraphImporter) registerComponentHandler(kV2GraphType, graphComponentHandler) +registerDataDisplayHandler(kV2GraphType, graphDataDisplayHandler) diff --git a/v3/src/components/graph/utilities/image-utils.test.ts b/v3/src/components/graph/utilities/image-utils.test.ts new file mode 100644 index 0000000000..9e52c2583a --- /dev/null +++ b/v3/src/components/graph/utilities/image-utils.test.ts @@ -0,0 +1,89 @@ +import { PixiPoints } from "../../data-display/pixi/pixi-points" +import { graphSnapshot } from "./image-utils" + +const mockPixiPoints: Partial = { + renderer: { + extract: { + canvas: jest.fn(() => { + const canvas = document.createElement("canvas") + canvas.toDataURL = jest.fn(() => "data:image/png;base64,") + canvas.width = 100 + canvas.height = 100 + return canvas + }) + } as any, + } as any, + stage: {} as any +} + +beforeAll(() => { + const mockImage = jest.fn(() => { + const img = document.createElement("img") + img.width = 100 + img.height = 100 + // Simulate async image loading. + Object.defineProperty(img, "src", { + set(url) { + setTimeout(() => { + if (img.onload) { + img.onload(new Event("load")) + } + }, 0) + }, + }) + return img + }) + + global.Image = mockImage as unknown as typeof Image +}) + +afterAll(() => { + delete (global as any).Image +}) + +const styleEl = document.createElement("style") +styleEl.textContent = ".codap-graph { background-color: gray; height: 100px; width: 100px; }" +const containerEl = document.createElement("div") +containerEl.classList.add("codap-graph") +const graphEl = document.createElement("div") +graphEl.classList.add("graph-plot") +const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg") +svgEl.classList.add("graph-svg") +const foreignObjectEl = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject") +const canvasElt = document.createElement("canvas") + +foreignObjectEl.appendChild(canvasElt) +svgEl.appendChild(foreignObjectEl) +graphEl.appendChild(svgEl) +containerEl.appendChild(graphEl) +document.head.appendChild(styleEl) +document.body.appendChild(containerEl) + +describe("graphSnaphsot", () => { + it("should return a data URL string when `asDataUrl` is true", async () => { + const svgElementsToImageOptions = { + rootEl: containerEl, + graphWidth: 100, + graphHeight: 100, + graphTitle: "Empty Test Graph", + asDataURL: true, + pixiPoints: mockPixiPoints as PixiPoints + } + const result = await graphSnapshot(svgElementsToImageOptions) + expect(typeof result).toBe("string") + expect(result).toContain("data:image/png;base64,00") + }) + it("should return a blob when `asDataUrl` is false", async () => { + const svgElementsToImageOptions = { + rootEl: containerEl, + graphWidth: 100, + graphHeight: 100, + graphTitle: "Empty Test Graph", + asDataURL: false, + pixiPoints: mockPixiPoints as PixiPoints + } + const result = await graphSnapshot(svgElementsToImageOptions) + expect(typeof result).toBe("object") + expect(result).toBeInstanceOf(Blob) + }) +}) diff --git a/v3/src/components/graph/utilities/image-utils.ts b/v3/src/components/graph/utilities/image-utils.ts new file mode 100644 index 0000000000..9a427038a7 --- /dev/null +++ b/v3/src/components/graph/utilities/image-utils.ts @@ -0,0 +1,255 @@ +import { Point } from "../../data-display/data-display-types" +import { PixiPoints } from "../../data-display/pixi/pixi-points" + +type Dimensions = { + width: number + height: number +} +type Job = { + coords: Point + dimensions: Dimensions + element: Element +} + +interface IGraphSnapshotOptions { + rootEl: HTMLElement + graphWidth: number + graphHeight: number + graphTitle: string + asDataURL: boolean + pixiPoints: PixiPoints +} + +export const graphSnapshot = (options: IGraphSnapshotOptions): Promise => { + const { rootEl, graphWidth, graphHeight, graphTitle, asDataURL, pixiPoints } = options + + const getCssText = (): string => { + const text: string[] = [] + for (let ix = 0; ix < document.styleSheets.length; ix++) { + try { + const styleSheet = document.styleSheets[ix] + if (styleSheet) { + const rules = styleSheet.rules || styleSheet.cssRules || [] + for (let jx = 0; jx < rules.length; jx++) { + const rule = rules[jx] + text.push(rule.cssText) + } + } + } catch (ex) { + console.warn(`Exception retrieving stylesheet: ${ex}`) + } + } + return text.join("\n") + } + + /** + * Converts a PixiJS canvas to an SVG image element. + * @param foreignObject {SVGForeignObjectElement} The foreignObject element containing the PixiJS canvas. + * @returns {SVGImageElement | undefined} An SVG image element representing the PixiJS canvas, or undefined. + */ + const imageFromPixiCanvas = (foreignObject: SVGForeignObjectElement): SVGImageElement | undefined => { + const extractedCanvas = pixiPoints.renderer?.extract.canvas(pixiPoints.stage) + if (!extractedCanvas?.toDataURL) return + + const width = foreignObject.getAttribute("width") ?? extractedCanvas.width.toString() + const height = foreignObject.getAttribute("height") ?? extractedCanvas.height.toString() + const x = foreignObject.getAttribute("x") || "0" + const y = foreignObject.getAttribute("y") || "0" + const dataURL = extractedCanvas.toDataURL("image/png") + const image = document.createElementNS("http://www.w3.org/2000/svg", "image") + image.setAttributeNS(null, "href", dataURL) + image.setAttributeNS(null, "x", x) + image.setAttributeNS(null, "y", y) + image.setAttributeNS(null, "width", width) + image.setAttributeNS(null, "height", height) + + return image + } + + const makeDataURLFromSVGElement = (svgEl: SVGSVGElement, dimensions: Dimensions): string => { + const svgClone = svgEl.cloneNode(true) as SVGSVGElement + svgClone.style.fill = "#f8f8f8" + svgClone.setAttribute("width", dimensions.width.toString()) + svgClone.setAttribute("height", dimensions.height.toString()) + + // grid lines are too dark without this tweak + const lines = svgClone.querySelectorAll("line") + lines.forEach(line => { + const stroke = line.getAttribute("stroke") + if (stroke === "rgb(211, 211, 211)") { + line.setAttribute("stroke", "rgb(230, 230, 230)") + } + }) + + const css = document.createElement("style") + css.textContent = getCssText() + // Append some custom rules to improve the output -- hopefully we can make this unnecessary later. + css.textContent += ` + .grid .tick line { + stroke: rgb(211, 211, 211); + stroke-opacity: 0.7; + } + line.divider, line.axis-line { + height: 1px; + stroke: rgb(211, 211, 211); + } + text.category-label { + fill: black; + font-family: arial, helvetica, sans-serif; + font-size: 9px; + } + ` + svgClone.insertBefore(css, svgClone.firstChild) + + // The PixiJS canvas inside the `graph-svg` SVG element requires special handling. We extract its + // content using PixiJS, create an image element using the extracted content, then replace the canvas + // element with the image element. + const foreignObject = svgClone.querySelector("foreignObject") + const pixiCanvas = foreignObject?.querySelector("canvas") + if (foreignObject && pixiCanvas) { + const image = imageFromPixiCanvas(foreignObject) + if (image) { + svgClone.replaceChild(image, foreignObject) + } + } + + // Serialize the SVG to a data URL + let svgData = new XMLSerializer().serializeToString(svgClone) + svgData = svgData.replace(/url\('[^#]*#/g, "url('#") + return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` + } + + const makeCanvas = (bgColor: string, x: number, y: number, width: number, height: number): HTMLCanvasElement => { + const newCanvas = document.createElement("canvas") + newCanvas.width = width + newCanvas.height = height + const ctx = newCanvas.getContext("2d") + + if (ctx) { + ctx.fillStyle = bgColor + ctx.fillRect(x, y, width, height) + } + + return newCanvas + } + + const makeCanvasBlob = (canvas: HTMLCanvasElement): Blob => { + const canvasDataURL = canvas.toDataURL("image/png") + const canvasData = atob(canvasDataURL.substring("data:image/png;base64,".length)) + const canvasAsArray = new Uint8Array(canvasData.length) + + for (let i = 0; i < canvasData.length; i++) { + canvasAsArray[i] = canvasData.charCodeAt(i) + } + + return new Blob([canvasAsArray.buffer], { type: "image/png" }) + } + + const makeSVGImage = (dataURL: string): Promise => { + return new Promise((resolve, reject) => { + try { + const img = new Image() + img.onload = () => resolve(img) + img.src = dataURL + } catch (ex) { + reject(ex) + } + }) + } + + const addTitle = (canvas: HTMLCanvasElement, bgColor: string, fgColor: string, title: string) => { + const ctx = canvas.getContext("2d") + if (ctx) { + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, graphWidth, 25) + ctx.fillStyle = fgColor + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.stroke() + ctx.font = "10pt MuseoSans-500" + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(title, graphWidth / 2, 14, graphWidth) + } + } + + const perform = async (job?: Job): Promise => { + if (!job) { + if (graphTitle) { + addTitle(mainCanvas, "transparent", "white", graphTitle) + } + return Promise.resolve(asDataURL ? mainCanvas.toDataURL("image/png") : makeCanvasBlob(mainCanvas)) + } + + const { coords, dimensions, element } = job + const { x, y } = coords + const { width, height } = dimensions + const elType = element.nodeName.toLowerCase() + const ctx = mainCanvas.getContext("2d") + + if (ctx) { + switch (elType) { + case "div": { + ctx.fillStyle = getComputedStyle(job.element).backgroundColor || "#f8f8f8" + ctx.fillRect(x, y, width, height) + break + } + case "svg": { + const svgEl = job.element as SVGSVGElement + const dataURL = makeDataURLFromSVGElement(svgEl, dimensions) + const svgImg = await makeSVGImage(dataURL) + ctx.drawImage(svgImg, x, y, width, height) + break + } + } + } + + return perform(jobList[jobIx++]) + } + + const getClassNames = (element: Element): string[] => { + if (element instanceof HTMLElement || element instanceof SVGElement) { + return Array.from(element.classList) + } + return [] + } + + const disallowedElementClasses = new Set([ + "axis-legend-attribute-menu", + "attribute-label-menu", + "chakra-icon", + "chakra-menu__menu-list", + "codap-component-corner", + "component-minimize-icon", + "component-resize-handle", + "droppable-axis", + "droppable-svg", + "header-right", + "legend", + "multi-legend", + ]) + + const isAllowedElement = (element: Element): boolean => { + const classNames = getClassNames(element) + return classNames.every((className) => !disallowedElementClasses.has(className)) + } + + const allElements = rootEl.querySelectorAll("div, svg") + const targetElements = Array.from(allElements).filter(isAllowedElement) + const mainCanvas = makeCanvas("#f8f8f8", 0, 0, graphWidth, graphHeight) + const jobList: Job[] = [] + let jobIx = 0 + + targetElements.forEach((element: Element) => { + const rect = element.getBoundingClientRect() + const rootRect = rootEl.getBoundingClientRect() + const left = rect.left - rootRect.left + const top = rect.top - rootRect.top + const coords = { x: left, y: top } + const dimensions = { width: rect.width, height: rect.height } + + jobList.push({ element, dimensions, coords }) + }) + + return perform(jobList[jobIx++]) +} diff --git a/v3/src/data-interactive/data-interactive-types.ts b/v3/src/data-interactive/data-interactive-types.ts index 4a68930680..e466faacc4 100644 --- a/v3/src/data-interactive/data-interactive-types.ts +++ b/v3/src/data-interactive/data-interactive-types.ts @@ -165,6 +165,9 @@ export interface DILogMessage { export interface DIUrl { URL: string } +export interface DIDataDisplay { + exportDataUri?: string +} export interface DIResources { attribute?: IAttribute @@ -179,6 +182,7 @@ export interface DIResources { component?: DIComponent dataContext?: IDataSet dataContextList?: IDataSet[] + dataDisplay?: DIDataDisplay error?: string global?: IGlobalValue interactiveFrame?: ITileModel @@ -198,7 +202,9 @@ export type DIValues = DISingleValues | DISingleValues[] | number | string[] // types returned as outputs by the API export type DIResultAttributes = { attrs: ICodapV2Attribute[] } -export type DIResultSingleValues = DICase | DIComponentInfo | DIGetCaseResult | DIGlobal | DIInteractiveFrame +export type DIResultSingleValues = DICase | DIComponentInfo | DIDataDisplay | DIGetCaseResult | DIGlobal + | DIInteractiveFrame + export type DIResultValues = DIResultSingleValues | DIResultSingleValues[] | DIAllCases | DIDeleteCollectionResult | DIUpdateItemResult | DIResultAttributes | number | number[] @@ -272,6 +278,7 @@ export interface DIResourceSelector { component?: string dataContext?: string dataContextList?: string + dataDisplay?: string global?: string interactiveFrame?: string item?: string diff --git a/v3/src/data-interactive/handlers/data-display-handler.ts b/v3/src/data-interactive/handlers/data-display-handler.ts new file mode 100644 index 0000000000..334fc0d8c8 --- /dev/null +++ b/v3/src/data-interactive/handlers/data-display-handler.ts @@ -0,0 +1,42 @@ +import { ITileContentModel } from "../../models/tiles/tile-content" +import { getTileContentInfo } from "../../models/tiles/tile-content-info" +import { kComponentTypeV3ToV2Map } from "../data-interactive-component-types" +import { registerDIHandler } from "../data-interactive-handler" +import { DIAsyncHandler, DIResources } from "../data-interactive-types" +import { componentNotFoundResult, dataDisplayNotFoundResult } from "./di-results" + +export interface DIDataDisplayHandler { + get: (content: ITileContentModel) => Maybe> +} + +// registry of data display handlers -- key is v2 tile type +export const diDataDisplayHandlers = new Map() + +export function registerDataDisplayHandler(type: string, handler: DIDataDisplayHandler) { + diDataDisplayHandlers.set(type, handler) +} + +export const diDataDisplayHandler: DIAsyncHandler = { + async get(resources: DIResources) { + const { component } = resources + if (!component) return componentNotFoundResult + + try { + const { content } = component + const v2Type = getTileContentInfo(content.type)?.getV2Type?.(content) ?? kComponentTypeV3ToV2Map[content.type] + const handler = diDataDisplayHandlers.get(v2Type) + const imgSnapshotRes = await handler?.get(component?.content) + const { exportDataUri, success } = imgSnapshotRes ?? {} + const values = success + ? { exportDataUri } + : { error: dataDisplayNotFoundResult } + + return { success, values } + } catch (e) { + console.error("Error in diDataDisplayHandler", e) + return { success: false, values: { error: dataDisplayNotFoundResult } } + } + } +} + +registerDIHandler("dataDisplay", diDataDisplayHandler) diff --git a/v3/src/data-interactive/handlers/di-results.ts b/v3/src/data-interactive/handlers/di-results.ts index a1ccbed913..4af0fc7895 100644 --- a/v3/src/data-interactive/handlers/di-results.ts +++ b/v3/src/data-interactive/handlers/di-results.ts @@ -7,6 +7,7 @@ export const collectionNotFoundResult = errorResult(t("V3.DI.Error.collectionNot export const componentNotFoundResult = errorResult(t("V3.DI.Error.componentNotFound")) export const couldNotParseQueryResult = errorResult(t("V3.DI.Error.couldNotParseQuery")) export const dataContextNotFoundResult = errorResult(t("V3.DI.Error.dataContextNotFound")) +export const dataDisplayNotFoundResult = errorResult(t("V3.DI.Error.dataDisplayNotFound")) export const itemNotFoundResult = errorResult(t("V3.DI.Error.itemNotFound")) export const valuesRequiredResult = errorResult(t("V3.DI.Error.valuesRequired")) diff --git a/v3/src/data-interactive/register-handlers.ts b/v3/src/data-interactive/register-handlers.ts index d31ad2da70..e2008b6271 100644 --- a/v3/src/data-interactive/register-handlers.ts +++ b/v3/src/data-interactive/register-handlers.ts @@ -17,6 +17,7 @@ import "./handlers/configuration-list-handler" import "./handlers/data-context-from-url-handler" import "./handlers/data-context-handler" import "./handlers/data-context-list-handler" +import "./handlers/data-display-handler" import "./handlers/formula-engine-handler" import "./handlers/global-handler" import "./handlers/global-list-handler" diff --git a/v3/src/data-interactive/resource-parser.ts b/v3/src/data-interactive/resource-parser.ts index c26d006a8c..24164010bb 100644 --- a/v3/src/data-interactive/resource-parser.ts +++ b/v3/src/data-interactive/resource-parser.ts @@ -79,7 +79,7 @@ export function resolveResources( const result: DIResources = { interactiveFrame } if (!resourceSelector.type || [ - 'component', 'componentList', 'dataContextList', 'document', 'formulaEngine', 'global', 'globalList', + 'component', 'componentList', 'dataContextList', 'dataDisplay', 'document', 'formulaEngine', 'global', 'globalList', 'interactiveFrame', 'logMessage', 'logMessageMonitor', 'undoableActionPerformed', 'undoChangeNotice' ].indexOf(resourceSelector.type) < 0) { // if no data context provided, and we are not creating one, the @@ -103,6 +103,11 @@ export function resolveResources( result.component = findTileFromNameOrId(component) } + if (resourceSelector.dataDisplay) { + const { dataDisplay } = resourceSelector + result.component = findTileFromNameOrId(dataDisplay) + } + if (resourceSelector.global) { const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager) result.global = globalManager?.getValueByName(resourceSelector.global) || diff --git a/v3/src/hooks/use-cfm-context.ts b/v3/src/hooks/use-cfm-context.ts new file mode 100644 index 0000000000..11de7d9e71 --- /dev/null +++ b/v3/src/hooks/use-cfm-context.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from "react" +import { CloudFileManager } from "@concord-consortium/cloud-file-manager" + +export const CfmContext = createContext>(undefined) + +export const useCfmContext = () => { + return useContext(CfmContext) +} diff --git a/v3/src/utilities/translation/lang/en-US.json5 b/v3/src/utilities/translation/lang/en-US.json5 index 83cb6f8ee5..6eddb3c68b 100644 --- a/v3/src/utilities/translation/lang/en-US.json5 +++ b/v3/src/utilities/translation/lang/en-US.json5 @@ -115,6 +115,7 @@ "V3.DI.Error.attributeNotFound": "Attribute not found", "V3.DI.Error.fieldRequired": "%@1 %@2: %@3 required", "V3.DI.Error.dataContextNotFound": "DataContext not found", + "V3.DI.Error.dataDisplayNotFound": "DataDisplay not found", "V3.DI.Error.caseMetadataNotFound": "CaseMetadata not found for %@", "V3.DI.Error.caseNotFound": "Case not found", "V3.DI.Error.collectionNotFound": "Collection not found", From cccbdb32627c4e25bc0fa1ff304e5741eccc162f Mon Sep 17 00:00:00 2001 From: eireland <7716653+eireland@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:01:10 +0000 Subject: [PATCH 2/2] Increment the build number --- v3/build_number.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v3/build_number.json b/v3/build_number.json index c74001dd63..a4154857d0 100644 --- a/v3/build_number.json +++ b/v3/build_number.json @@ -1 +1 @@ -{"buildNumber":2086} +{"buildNumber":2087}