diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a982b15..25ecaed66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ You can also check the # Unreleased -Nothing yet. +- Features + - It's now possible to export charts as images # [5.0.2] - 2024-11-28 diff --git a/app/charts/map/map.tsx b/app/charts/map/map.tsx index bb2a3fd5e..7bb3ac29f 100644 --- a/app/charts/map/map.tsx +++ b/app/charts/map/map.tsx @@ -32,6 +32,7 @@ import { GeoFeature, GeoPoint } from "@/domain/data"; import { Icon, IconName } from "@/icons"; import { useLocale } from "@/src"; import useEvent from "@/utils/use-event"; +import { DISABLE_SCREENSHOT_ATTR } from "@/utils/use-screenshot"; // supported was removed as of maplibre-gl v3.0.0, so we need to add it back const maplibregl = { ...maplibreglraw, supported }; @@ -391,7 +392,7 @@ export const MapComponent = () => { return ( <> {locked ? null : ( -
+
@@ -403,6 +404,8 @@ export const MapComponent = () => { initialViewState={defaultViewState} mapLib={maplibregl} mapStyle={mapStyle} + // Important so we can take a screenshot of the map + preserveDrawingBuffer style={{ position: "absolute", left: 0, diff --git a/app/charts/shared/brush/index.tsx b/app/charts/shared/brush/index.tsx index 17560a1f0..a3447fb20 100644 --- a/app/charts/shared/brush/index.tsx +++ b/app/charts/shared/brush/index.tsx @@ -32,6 +32,7 @@ import { } from "@/stores/interactive-filters"; import { useTransitionStore } from "@/stores/transition"; import { getTextWidth } from "@/utils/get-text-width"; +import { DISABLE_SCREENSHOT_ATTR } from "@/utils/use-screenshot"; // Brush constants const HANDLE_HEIGHT = 14; @@ -371,6 +372,7 @@ export const BrushTime = () => { return fullData.length ? ( { + const componentIds = extractChartConfigComponentIds({ + chartConfig, + includeFilters: false, + }); + + return componentIds + .map((id) => components.find((component) => component.id === id)) + .filter(truthy); // exclude potential joinBy components +}; + /** Use to remove missing values from chart data. */ export const usePlottableData = ( data: Observation[], diff --git a/app/charts/shared/containers.tsx b/app/charts/shared/containers.tsx index 652cc960c..8c2a6efdb 100644 --- a/app/charts/shared/containers.tsx +++ b/app/charts/shared/containers.tsx @@ -12,6 +12,7 @@ import { useConfiguratorState, } from "@/configurator"; import { useTransitionStore } from "@/stores/transition"; +import { DISABLE_SCREENSHOT_ATTR } from "@/utils/use-screenshot"; export const useStyles = makeStyles<{}, {}, "chartContainer">(() => ({ chartContainer: { @@ -70,6 +71,7 @@ export const ChartSvg = ({ children }: { children: ReactNode }) => { > {interactiveFiltersConfig?.calculation.active && ( { return ( <> {showSearch && ( - + ( }) ); +export const CHART_FOOTNOTES_CLASS_NAME = "chart-footnotes"; + export const ChartFootnotes = ({ dataSource, chartConfig, @@ -57,14 +58,7 @@ export const ChartFootnotes = ({ }) => { const locale = useLocale(); const usedComponents = useMemo(() => { - const componentIds = extractChartConfigComponentIds({ - chartConfig, - includeFilters: false, - }); - - return componentIds - .map((id) => components.find((component) => component.id === id)) - .filter(truthy); // exclude potential joinBy components + return extractChartConfigUsedComponents(chartConfig, { components }); }, [chartConfig, components]); const [{ data }] = useDataCubesMetadataQuery({ variables: { @@ -81,7 +75,10 @@ export const ChartFootnotes = ({ const formatLocale = useTimeFormatLocale(); return ( - :not(:last-child)": { mb: 3 } }}> + :not(:last-child)": { mb: 3 } }} + > {data?.dataCubesMetadata.map((metadata) => (
))} - {showVisualizeLink ? : null} + {showVisualizeLink ? ( + + ) : null} ); }; @@ -289,11 +288,12 @@ const ChartFootnotesComboLineSingle = ({ ) : null; }; -export const VisualizeLink = () => { +export const VisualizeLink = ({ createdWith }: { createdWith: ReactNode }) => { const locale = useLocale(); + return ( - Created with + {createdWith} { const [state] = useConfiguratorState(hasChartConfigs); @@ -393,6 +402,7 @@ const ChartPreviewInner = ({ chartKey?: string | null; actionElementSlot?: ReactNode; }) => { + const ref = useRef(null); const [state, dispatch] = useConfiguratorState(); const configuring = isConfiguring(state); const chartConfig = getChartConfig(state, chartKey); @@ -421,7 +431,7 @@ const ChartPreviewInner = ({ })), }, }); - const { isTable, containerRef, containerHeight } = useChartTablePreview(); + const { isTable } = useChartTablePreview(); const dimensions = components?.dataCubesComponents.dimensions; const measures = components?.dataCubesComponents.measures; const allComponents = useMemo(() => { @@ -433,7 +443,7 @@ const ChartPreviewInner = ({ }, [dimensions, measures]); return ( - + {children} {hasChartConfigs(state) && ( @@ -474,6 +484,10 @@ const ChartPreviewInner = ({ }) : undefined } + {...{ + [DISABLE_SCREENSHOT_ATTR_KEY]: + !chartConfig.meta.title[locale], + }} /> ) : ( // We need to have a span here to keep the space between the @@ -488,7 +502,11 @@ const ChartPreviewInner = ({ mt: "-0.33rem", }} > - + {actionElementSlot} @@ -507,6 +525,10 @@ const ChartPreviewInner = ({ } : undefined } + {...{ + [DISABLE_SCREENSHOT_ATTR_KEY]: + !chartConfig.meta.description[locale], + }} /> ) : ( // We need to have a span here to keep the space between the @@ -538,15 +560,7 @@ const ChartPreviewInner = ({ top: BANNER_MARGIN_TOP, }} /> -
+ {isTable ? ( )} -
+ - {state.chartConfigs.length !== 1 && } + {state.chartConfigs.length !== 1 && ( + + )}
) : ( <> @@ -252,8 +257,7 @@ const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { } = props; const { meta } = chartConfig; const rootRef = useRef(null); - const { isTable, containerRef, containerHeight, computeContainerHeight } = - useChartTablePreview(); + const { isTable, computeContainerHeight } = useChartTablePreview(); const metadataPanelOpen = useStore(metadataPanelStore, (state) => state.open); const shouldShrink = useMemo(() => { const rootWidth = rootRef.current?.getBoundingClientRect().width; @@ -317,8 +321,8 @@ const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { return ( {children} @@ -392,6 +396,8 @@ const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { @@ -415,16 +421,7 @@ const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { allowMultipleOpen: true, }} /> - -
+ {isTable ? ( { dashboardFilters={state.dashboardFilters} /> )} -
+ ( @@ -75,8 +110,10 @@ export const ChartControls = ({ chartConfig, dashboardFilters, }); + return ( { + const locale = useLocale(); const [state, dispatch] = useConfiguratorState(hasChartConfigs); const [anchor, setAnchor] = useState(null); const handleClose = useEventCallback(() => setAnchor(null)); const chartConfig = getChartConfig(state, chartKey); const { setIsTableRaw } = useChartTablePreview(); + // Reset back to chart view when switching chart type. useEffect(() => { setIsTableRaw(false); }, [chartConfig.chartType, setIsTableRaw]); + const disableButton = isPublished(state) && state.layout.type === "dashboard" && chartConfig.chartType === "table"; + const screenshotName = useMemo(() => { + const date = timeUnitToFormatter.Day(new Date()); + const label = chartConfig.meta.title[locale] || chartConfig.chartType; + return `${date}_${label}`; + }, [chartConfig.meta.title, chartConfig.chartType, locale]); + return disableButton ? null : ( <> setAnchor(ev.currentTarget)} sx={{ height: "fit-content" }} + {...DISABLE_SCREENSHOT_ATTR} > @@ -149,10 +200,19 @@ export const ChartMoreButton = ({ {isPublished(state) ? (
{chartConfig.chartType !== "table" ? ( - + <> + + + ) : null} {state.layout.type !== "dashboard" && configKey ? ( <> @@ -180,10 +240,19 @@ export const ChartMoreButton = ({ onSuccess={handleClose} /> {chartConfig.chartType !== "table" ? ( - + <> + + + ) : null} {state.chartConfigs.length > 1 ? ( { `${window.location.origin}/${locale}/create/new?copy=${configKey}` ); }, [configKey, locale]); + return ( { useEffect(() => { setShareUrl(`${window.location.origin}/${locale}/v/${configKey}`); }, [configKey, locale]); + return ( { const locale = useLocale(); const [_, dispatch] = useConfiguratorState(hasChartConfigs); + return ( void; }) => { const { isTable, setIsTable } = useChartTablePreview(); + return ( ); }; + +const DownloadPNGImageMenuActionItem = ({ + configKey, + chartKey, + components, + screenshotName, + screenshotNode, +}: { + configKey?: string; + chartKey: string; + components: Component[]; +} & Omit) => { + const modifyNode = useModifyNode(); + const metadata = usePNGMetadata({ + configKey, + chartKey, + components, + }); + const { loading, screenshot } = useScreenshot({ + type: "png", + screenshotName, + screenshotNode, + modifyNode, + pngMetadata: metadata, + }); + + return ( + + ); +}; + +const useModifyNode = () => { + const theme = useTheme(); + const chartWithFiltersClasses = useChartWithFiltersClasses(); + + return useCallback( + async (clonedNode: HTMLElement, originalNode: HTMLElement) => { + // We need to explicitly set the height of the chart container to the height + // of the chart, as otherwise the screenshot won't work for free canvas charts. + const tablePreviewWrapper = clonedNode.querySelector( + `.${TABLE_PREVIEW_WRAPPER_CLASS_NAME}` + ) as HTMLElement | null; + + if (tablePreviewWrapper) { + const chart = originalNode.querySelector( + `.${chartWithFiltersClasses.chartWithFilters}` + ); + + if (chart) { + const height = chart.clientHeight; + tablePreviewWrapper.style.height = `${height}px`; + } + } + + const footnotes = clonedNode.querySelector( + `.${CHART_FOOTNOTES_CLASS_NAME}` + ); + + if (footnotes) { + const container = document.createElement("div"); + footnotes.appendChild(container); + const root = createRoot(container); + root.render( + + ); + await animationFrame(); + } + + // Remove some elements that should not be included in the screenshot. + // For maps, we can't apply custom classes to internal elements, so we need + // to remove them here. + clonedNode.querySelector(".maplibregl-ctrl")?.remove(); + + // Every text element should be dark-grey (currently we use primary.main to + // indicate interactive elements, which doesn't make sense for screenshots) + // and not have underlines. + const color = theme.palette.grey[700]; + select(clonedNode) + .selectAll("*") + .style("color", color) + .style("text-decoration", "none"); + // SVG elements have fill instead of color. Here we only target text elements, + // to avoid changing the color of other SVG elements (charts). + select(clonedNode).selectAll("text").style("fill", color); + }, + [chartWithFiltersClasses.chartWithFilters, theme.palette.grey] + ); +}; + +const usePNGMetadata = ({ + chartKey, + configKey, + components, +}: { + chartKey: string; + configKey?: string; + components: Component[]; +}): UseScreenshotProps["pngMetadata"] => { + const locale = useLocale(); + const [state] = useConfiguratorState(hasChartConfigs); + const chartConfig = getChartConfig(state, chartKey); + + const usedComponents = useMemo(() => { + return extractChartConfigUsedComponents(chartConfig, { components }); + }, [chartConfig, components]); + + const [{ data }] = useDataCubesMetadataQuery({ + variables: { + sourceType: state.dataSource.type, + sourceUrl: state.dataSource.url, + locale, + cubeFilters: uniqBy( + usedComponents.map((component) => ({ iri: component.cubeIri })), + "iri" + ), + }, + pause: !usedComponents.length, + }); + + return useMemo(() => { + const publisher = data?.dataCubesMetadata + .map((cube) => + cube.contactPoint + ? `${cube.contactPoint.name} (${cube.contactPoint.email})` + : cube.creator?.label ?? cube.publisher + ) + .join(", "); + const publisherMetadata = publisher + ? { key: "Publisher", value: publisher } + : null; + const publishURL = configKey + ? `${window.location.origin}/${locale}/v/${configKey}` + : null; + const publishURLMetadata = publishURL + ? { key: "Publish URL", value: publishURL } + : null; + const datasets = data?.dataCubesMetadata + .map((cube) => `${cube.title} ${cube.version ? `(${cube.version})` : ""}`) + .join(", "); + const datasetsMetadata = datasets + ? { key: "Dataset", value: datasets } + : null; + + const metadata = [publisherMetadata, publishURLMetadata, datasetsMetadata] + .filter(truthy) + .map(({ key, value }) => `${key}: ${value}`) + .join(" | "); + + return metadata ? [{ key: "Comment", value: deburr(metadata) }] : []; + }, [configKey, data?.dataCubesMetadata, locale]); +}; diff --git a/app/components/chart-table-preview.tsx b/app/components/chart-table-preview.tsx index de9edc3af..9dbf3faa4 100644 --- a/app/components/chart-table-preview.tsx +++ b/app/components/chart-table-preview.tsx @@ -95,3 +95,24 @@ export const ChartTablePreviewProvider = ({ ); }; + +export const TABLE_PREVIEW_WRAPPER_CLASS_NAME = "table-preview-wrapper"; + +export const TablePreviewWrapper = ({ children }: { children: ReactNode }) => { + const { containerRef, containerHeight } = useChartTablePreview(); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/components/chart-with-filters.tsx b/app/components/chart-with-filters.tsx index 37240eb09..909c21ffc 100644 --- a/app/components/chart-with-filters.tsx +++ b/app/components/chart-with-filters.tsx @@ -160,7 +160,7 @@ type ChartWithFiltersProps = { dashboardFilters: DashboardFiltersConfig | undefined; }; -const useStyles = makeStyles(() => ({ +export const useChartWithFiltersClasses = makeStyles(() => ({ chartWithFilters: { width: "100%", height: "100%", @@ -172,7 +172,8 @@ export const ChartWithFilters = forwardRef< ChartWithFiltersProps >((props, ref) => { useSyncInteractiveFilters(props.chartConfig, props.dashboardFilters); - const classes = useStyles(); + const classes = useChartWithFiltersClasses(); + return (
diff --git a/app/components/debug-panel/DebugPanel.tsx b/app/components/debug-panel/DebugPanel.tsx index 164a294b8..779ed2dd4 100644 --- a/app/components/debug-panel/DebugPanel.tsx +++ b/app/components/debug-panel/DebugPanel.tsx @@ -27,6 +27,7 @@ import SvgIcChevronRight from "@/icons/components/IcChevronRight"; import { useLocale } from "@/src"; import { useInteractiveFiltersGetState } from "@/stores/interactive-filters"; import useEvent from "@/utils/use-event"; +import { DISABLE_SCREENSHOT_ATTR } from "@/utils/use-screenshot"; const DebugInteractiveFilters = () => { const getInteractiveFiltersState = useInteractiveFiltersGetState(); @@ -200,7 +201,12 @@ const DebugPanel = (props: DebugPanelProps) => { const classes = useStyles(); return ( - + diff --git a/app/components/drag-handle.tsx b/app/components/drag-handle.tsx index e4da0cd1f..3b429bdd7 100644 --- a/app/components/drag-handle.tsx +++ b/app/components/drag-handle.tsx @@ -3,6 +3,7 @@ import clsx from "clsx"; import { forwardRef, Ref } from "react"; import { Icon } from "@/icons"; +import { DISABLE_SCREENSHOT_ATTR } from "@/utils/use-screenshot"; import { useIconStyles } from "./chart-selection-tabs"; @@ -18,6 +19,7 @@ export const DragHandle = forwardRef( return ( ({ whiteSpace: "normal", })) as typeof MenuItem; export type MenuActionProps = { + disabled?: boolean; label: string | NonNullable; trailingIconName?: IconName; leadingIconName?: IconName; @@ -50,7 +51,7 @@ export type MenuActionProps = { export const MenuActionItem = ( props: MenuActionProps & { as: "menuitem" | "button" } ) => { - const { label, trailingIconName, leadingIconName } = props; + const { disabled, label, trailingIconName, leadingIconName } = props; const { isOpen: isConfirmationOpen, open: openConfirmation, @@ -97,6 +98,7 @@ export const MenuActionItem = ( return (