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,
}}
/>
-
+
- {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 (