diff --git a/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx b/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx index 2c3aece1..d2af3f47 100644 --- a/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx +++ b/apps/web/src/components/dashboard-builder/canvas/dashboard-canvas.tsx @@ -10,8 +10,10 @@ import type { WidgetMode, } from "@/components/dashboard-builder/types" import { useDashboardActions } from "@/components/dashboard-builder/dashboard-actions-context" +import { useDashboardTimeRange } from "@/components/dashboard-builder/dashboard-providers" import { WidgetActionsProvider } from "@/components/dashboard-builder/widgets/widget-actions-context" import { useWidgetData } from "@/hooks/use-widget-data" +import { zoomRangeToWarehouse } from "@/lib/time-utils" import { ChartWidget } from "@/components/dashboard-builder/widgets/chart-widget" import { StatWidget } from "@/components/dashboard-builder/widgets/stat-widget" import { GaugeWidget } from "@/components/dashboard-builder/widgets/gauge-widget" @@ -39,6 +41,7 @@ const visualizationRegistry: Record< dataState: WidgetDataState display: WidgetDisplayConfig mode: WidgetMode + onZoomSelect?: (range: { startBucket: string; endBucket: string }) => void }> > = { chart: ChartWidget, @@ -97,16 +100,42 @@ function useInViewportSticky() { return { ref, visible } } -const WidgetRenderer = memo(function WidgetRenderer({ widget }: { widget: DashboardWidget }) { +const WidgetRenderer = memo(function WidgetRenderer({ + widget, + readOnly, +}: { + widget: DashboardWidget + readOnly: boolean +}) { const { mode } = useDashboardActions() + const { + actions: { setTimeRange }, + } = useDashboardTimeRange() const { ref, visible } = useInViewportSticky() const { dataState } = useWidgetData(widget, visible) const Visualization = visualizationRegistry[widget.visualization] ?? visualizationRegistry.chart + // Drag-to-zoom: a chart hands back the bucket timestamps at each end of the + // dragged window; narrow the dashboard time range to that absolute window. + // Disabled in read-only mode (e.g. previewing a historical version) so a + // drag can't mutate the live dashboard time range. + const handleZoomSelect = useCallback( + (range: { startBucket: string; endBucket: string }) => { + const resolved = zoomRangeToWarehouse(range) + if (resolved) setTimeRange({ type: "absolute", ...resolved }) + }, + [setTimeRange], + ) + return (
- +
) @@ -185,7 +214,7 @@ export function DashboardCanvas({ widgets, readOnly = false }: DashboardCanvasPr > {widgets.map((widget) => (
- +
))} diff --git a/apps/web/src/components/dashboard-builder/widgets/chart-expand-modal.tsx b/apps/web/src/components/dashboard-builder/widgets/chart-expand-modal.tsx new file mode 100644 index 00000000..4d9611da --- /dev/null +++ b/apps/web/src/components/dashboard-builder/widgets/chart-expand-modal.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react" + +import { cn } from "@maple/ui/utils" +import { ChartLegendSlotContext } from "@maple/ui/components/ui/chart" +import { Dialog, DialogPopup, DialogTitle } from "@maple/ui/components/ui/dialog" +import { useMemo } from "react" + +interface ChartExpandModalProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + /** The chart, rendered larger to fill the modal body. */ + children: ReactNode +} + +/** + * A near-fullscreen, centered modal that renders a chart at a larger size. + * Reused by dashboard chart widgets and the service/home metrics grid so the + * expand affordance and dialog wiring live in one place. + * + * The body provides its own `ChartLegendSlotContext` (the in-modal chart shows + * its legend inline rather than hoisting it into a widget header), so the + * expanded chart renders independently of the parent widget's legend slot. + */ +export function ChartExpandModal({ open, onOpenChange, title, children }: ChartExpandModalProps) { + // The expanded chart renders its legend inline (legend is promoted to + // "visible"), so the hoist slot is a no-op sink — it just absorbs any items a + // chart tries to hoist without forcing a wasted re-render of the modal. + const legendSlot = useMemo(() => ({ setItems: () => {} }), []) + + return ( + + + {title} +
+ + {open ? children : null} + +
+
+
+ ) +} diff --git a/apps/web/src/components/dashboard-builder/widgets/chart-widget.tsx b/apps/web/src/components/dashboard-builder/widgets/chart-widget.tsx index 6956c2f8..8fbc58b9 100644 --- a/apps/web/src/components/dashboard-builder/widgets/chart-widget.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/chart-widget.tsx @@ -2,6 +2,7 @@ import { memo, Suspense } from "react" import { getChartById } from "@maple/ui/components/charts/registry" import { ChartSkeleton } from "@maple/ui/components/charts/_shared/chart-skeleton" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { WidgetFrame } from "@/components/dashboard-builder/widgets/widget-shell" import type { WidgetDataState, WidgetDisplayConfig, WidgetMode } from "@/components/dashboard-builder/types" @@ -14,6 +15,8 @@ interface ChartWidgetProps { onConfigure?: () => void onCreateAlert?: () => void onFix?: () => void + /** Drag-to-zoom on time-series charts. See `BaseChartProps.onZoomSelect`. */ + onZoomSelect?: (range: { startBucket: string; endBucket: string }) => void } export const ChartWidget = memo(function ChartWidget({ @@ -25,7 +28,9 @@ export const ChartWidget = memo(function ChartWidget({ onConfigure, onCreateAlert, onFix, + onZoomSelect, }: ChartWidgetProps) { + const { effectiveTimezone } = useTimezonePreference() const chartId = display.chartId ?? "gradient-area" const entry = getChartById(chartId) if (!entry) return null @@ -37,6 +42,29 @@ export const ChartWidget = memo(function ChartWidget({ const seriesStats = display.chartPresentation?.seriesStats ?? legend !== "hidden" const tooltip = display.chartPresentation?.tooltip + const renderChart = (expanded: boolean) => ( +