Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"fflate": "^0.8.2",
"globals": "^16.0.0",
"gridstack": "^11.3.0",
"html-to-image": "^1.11.13",
"jsdom": "^26.0.0",
"json-schema": "^0.4.0",
"lucide-svelte": "^0.298.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
import MeasureChart from "./measure-chart/MeasureChart.svelte";
import MeasureChartXAxis from "./measure-chart/MeasureChartXAxis.svelte";
import { ScrubController } from "./measure-chart/ScrubController";
import ThreeDot from "@rilldata/web-common/components/icons/ThreeDot.svelte";
import ScreenshotContainer from "@rilldata/web-common/features/dashboards/time-series/ScreenshotContainer.svelte";

const { rillTime } = featureFlags;

Expand Down Expand Up @@ -179,6 +181,9 @@
$: annotationsEnabled =
!!$exploreValidSpec.data?.metricsView?.annotations?.length;

let screenshotDialogOpen = false;
let screenshotDialogMeasure: MetricsViewSpecMeasure | undefined = undefined;

// Pan handler
function handlePan(direction: "left" | "right") {
const panRange = $getNewPanRange(direction);
Expand Down Expand Up @@ -253,6 +258,11 @@
measureSelection.clear();
}
}

function openScreenshotDialog(measure: MetricsViewSpecMeasure) {
screenshotDialogMeasure = measure;
screenshotDialogOpen = true;
}
</script>

<svelte:window onclick={maybeClearMeasureSelection} />
Expand Down Expand Up @@ -392,39 +402,54 @@
/>

{#if activeTimeGrain}
<MeasureChart
{measure}
{scrubController}
{connectNulls}
tddChartType={tddChartType ?? TDDChart.DEFAULT}
metricsViewName={chartMetricsViewName}
where={chartWhere}
{timeDimension}
interval={chartInterval}
comparisonInterval={chartComparisonInterval}
timeGranularity={activeTimeGrain}
timeZone={selectedTimezone}
ready={chartReady}
{chartScrubInterval}
{comparisonDimension}
dimensionValues={chartDimensionValues}
dimensionWhere={whereFilter}
{annotationsEnabled}
canPanLeft={$canPanLeft}
canPanRight={$canPanRight}
onPanLeft={() => handlePan("left")}
onPanRight={() => handlePan("right")}
{showComparison}
{showTimeDimensionDetail}
dynamicYAxis={dynamicYAxisScale}
onScrub={handleScrub}
onScrubClear={() => {
metricsExplorerStore.setSelectedScrubRange(
exploreName,
undefined,
);
}}
/>
<div class="relative">
<MeasureChart
{measure}
{scrubController}
{connectNulls}
tddChartType={tddChartType ?? TDDChart.DEFAULT}
metricsViewName={chartMetricsViewName}
where={chartWhere}
{timeDimension}
interval={chartInterval}
comparisonInterval={chartComparisonInterval}
timeGranularity={activeTimeGrain}
timeZone={selectedTimezone}
ready={chartReady}
{chartScrubInterval}
{comparisonDimension}
dimensionValues={chartDimensionValues}
dimensionWhere={whereFilter}
{annotationsEnabled}
canPanLeft={$canPanLeft}
canPanRight={$canPanRight}
onPanLeft={() => handlePan("left")}
onPanRight={() => handlePan("right")}
{showComparison}
{showTimeDimensionDetail}
dynamicYAxis={dynamicYAxisScale}
onScrub={handleScrub}
onScrubClear={() => {
metricsExplorerStore.setSelectedScrubRange(
exploreName,
undefined,
);
}}
/>

<DropdownMenu.Root>
<DropdownMenu.Trigger class="absolute right-2 top-0">
<ThreeDot />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => openScreenshotDialog(measure)}
>
Download as PNG
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{:else}
<div class="flex items-center justify-center w-24">
<Spinner status={EntityStatus.Running} />
Expand All @@ -442,3 +467,23 @@
}}
onReplace={createPivot}
/>

{#if screenshotDialogMeasure}
<ScreenshotContainer
bind:open={screenshotDialogOpen}
measure={screenshotDialogMeasure}
metricsViewName={chartMetricsViewName}
where={chartWhere}
{timeDimension}
{timeStart}
{timeEnd}
{comparisonTimeStart}
{comparisonTimeEnd}
interval={chartInterval}
comparisonInterval={chartComparisonInterval}
timeGranularity={activeTimeGrain}
timeZone={selectedTimezone}
{showComparison}
ready={chartReady}
/>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<script lang="ts">
import { Button } from "@rilldata/web-common/components/button";
import * as Dialog from "@rilldata/web-common/components/dialog";
import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types";
import type {
MetricsViewSpecMeasure,
V1Expression,
V1TimeGrain,
} from "@rilldata/web-common/runtime-client";
import { toPng } from "html-to-image";
import { DateTime, Interval } from "luxon";
import MeasureBigNumber from "../big-number/MeasureBigNumber.svelte";
import MeasureChart from "./measure-chart/MeasureChart.svelte";
import MeasureChartXAxis from "./measure-chart/MeasureChartXAxis.svelte";
import { ScrubController } from "./measure-chart/ScrubController";
import { prettyFormatTimeRange } from "@rilldata/web-common/lib/time/ranges/formatter.ts";
import ExploreFilterChipsReadOnly from "@rilldata/web-common/features/dashboards/filters/ExploreFilterChipsReadOnly.svelte";
import ThemeProvider from "@rilldata/web-common/features/dashboards/ThemeProvider.svelte";
import { activeDashboardTheme } from "@rilldata/web-common/features/themes/active-dashboard-theme.ts";

export let open = false;
export let measure: MetricsViewSpecMeasure;
export let metricsViewName: string;
export let where: V1Expression | undefined = undefined;
export let timeDimension: string | undefined = undefined;
export let timeStart: string | undefined = undefined;
export let timeEnd: string | undefined = undefined;
export let comparisonTimeStart: string | undefined = undefined;
export let comparisonTimeEnd: string | undefined = undefined;
export let interval: Interval<true> | undefined = undefined;
export let comparisonInterval: Interval<true> | undefined = undefined;
export let timeGranularity: V1TimeGrain | undefined = undefined;
export let timeZone: string = "UTC";
export let showComparison = false;
export let ready = true;

// Inert scrub controller — interactions are not needed for screenshots.
const scrubController = new ScrubController();

let captureNode: HTMLDivElement;
let downloading = false;

$: formattedTimeRange = interval
? prettyFormatTimeRange(interval, timeGranularity)
: "";
$: generatedTime = prettyFormatTimeRange(
Interval.fromDateTimes(DateTime.now(), DateTime.now()),
timeGranularity,
);

const SVG_PROPS = [
"fill",
"fill-opacity",
"stroke",
"stroke-width",
"stroke-opacity",
"stroke-dasharray",
"stroke-linecap",
"opacity",
"font-family",
"font-size",
"font-weight",
"color",
];

function inlineSvgStyles(root: HTMLElement) {
root.querySelectorAll("svg, svg *").forEach((el) => {
const cs = getComputedStyle(el);
const inline = SVG_PROPS.map(
(p) => `${p}: ${cs.getPropertyValue(p)}`,
).join("; ");
el.setAttribute("style", `${inline}; ${el.getAttribute("style") ?? ""}`);
});
}

async function downloadScreenshot() {
if (!captureNode) return;
downloading = true;
try {
inlineSvgStyles(captureNode);
await document.fonts.ready;
const url = await toPng(captureNode, { cacheBust: true });
const link = document.createElement("a");
link.download = `${measure.name ?? "chart"}_${formattedTimeRange || generatedTime}.png`;
link.href = url;
link.click();
} finally {
downloading = false;
}
}
</script>

<Dialog.Root bind:open>
<Dialog.Content class="max-w-3xl flex flex-col gap-y-4">
<Dialog.Header>
<Dialog.Title>Export chart</Dialog.Title>
</Dialog.Header>

<ThemeProvider theme={$activeDashboardTheme} applyLayout={false}>
<div
bind:this={captureNode}
class="flex flex-col gap-y-3 p-4 bg-surface-background border rounded-md"
>
<header class="flex flex-row gap-y-0.5">
<div class="flex flex-col">
<h2 class="text-base font-semibold text-fg-base">
{measure.displayName || measure.name}
</h2>
{#if measure.description}
<p class="text-xs text-fg-muted">{measure.description}</p>
{/if}
</div>
<div class="grow"></div>
<div>{formattedTimeRange}</div>
</header>

<ExploreFilterChipsReadOnly
metricsViewNames={[metricsViewName]}
filters={where}
dimensionsWithInlistFilter={[]}
dimensionThresholdFilters={[]}
/>

<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
{#if timeGranularity}
<div class="col-span-2 grid grid-cols-subgrid">
<div></div>
<MeasureChartXAxis {interval} {timeGranularity} />
</div>
{/if}

<MeasureBigNumber
{measure}
{metricsViewName}
{where}
{timeDimension}
{timeStart}
{timeEnd}
{comparisonTimeStart}
{comparisonTimeEnd}
{showComparison}
{ready}
/>

{#if timeGranularity}
<MeasureChart
{measure}
{scrubController}
tddChartType={TDDChart.DEFAULT}
{metricsViewName}
{where}
{timeDimension}
{interval}
{comparisonInterval}
{timeGranularity}
{timeZone}
{ready}
{showComparison}
connectNulls={true}
/>
{/if}
</div>

<footer class="flex items-center justify-between text-xs text-fg-muted">
<span>Rill</span>
<span>Generated {generatedTime}</span>
</footer>
</div>
</ThemeProvider>

<Dialog.Footer>
<Button type="secondary" onClick={() => (open = false)}>Cancel</Button>
<Button
type="primary"
disabled={downloading}
onClick={downloadScreenshot}
>
{downloading ? "Generating…" : "Download PNG"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
1 change: 1 addition & 0 deletions web-common/src/features/themes/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function createResolvedThemeStore(
queryClient,
);
return themeQuery.subscribe(($themeQuery) => {
console.log(name, $themeQuery.data);
if ($themeQuery.data?.theme?.spec) {
set(new Theme($themeQuery.data.theme.spec));
} else {
Expand Down
Loading