diff --git a/apps/web/src/lab/bench/chart-bench.ts b/apps/web/src/lab/bench/chart-bench.ts index efc70943db..8f4bb8405a 100644 --- a/apps/web/src/lab/bench/chart-bench.ts +++ b/apps/web/src/lab/bench/chart-bench.ts @@ -9,12 +9,14 @@ import { ChartRuntime, isChartRuntimeReady, LoadChartRuntime } from "@maple/view import type { Resources } from "../../platform/resources" // The chart benches' shared state: a canvas chart per id, -// the chart runtime, the resolved theme, and one linked cursor across them. +// the chart runtime, the resolved theme, one linked cursor across them, and the span a drag zoomed to. export const Model = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String, charts: Schema.Record(Schema.String, Chart.Model), + /** The span the last drag selected; a page writes it to its URL, a bench narrows its rows. */ + zoom: Schema.NullOr(Schema.Struct({ startMs: Schema.Number, endMs: Schema.Number })), ...ChartEnvFields, }) export type Model = typeof Model.Type @@ -23,6 +25,7 @@ export const Message = defineMessageUnion({ GotChartMessage: { chartId: Schema.String, message: Chart.Message }, ...ChartEnvMessages, CompletedMountLinkedCursor: {}, + SelectedChartRange: { startMs: Schema.Number, endMs: Schema.Number }, }) export type Message = typeof Message.Type @@ -37,7 +40,7 @@ export const benchFlags: Effect.Effect = Effect.all({ type Return = Update.Return export const init = (flags: BenchFlags): Return => ({ - model: { ...flags, charts: {}, chartRuntime: ChartRuntime.Loading(), themeColors: null }, + model: { ...flags, charts: {}, zoom: null, chartRuntime: ChartRuntime.Loading(), themeColors: null }, commands: [LoadChartRuntime()], }) @@ -58,6 +61,7 @@ export const update = (model: Model, message: Message): Return => model: { ...model, chartRuntime: ChartRuntime.Failed({ reason }) }, }), CompletedMountLinkedCursor: () => ({ model }), + SelectedChartRange: (zoom) => ({ model: { ...model, zoom } }), }) export const isReady = (model: Model): boolean => diff --git a/apps/web/src/lab/bench/service-detail-bench.ts b/apps/web/src/lab/bench/service-detail-bench.ts index 38caf9917f..7fac64fa46 100644 --- a/apps/web/src/lab/bench/service-detail-bench.ts +++ b/apps/web/src/lab/bench/service-detail-bench.ts @@ -1,13 +1,14 @@ +import { parseBucketMs } from "@maple/ui/lib/format" import { Array as Arr } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import type { FixedMetricRow } from "@maple/views/charts/chart-spec" -import { MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors } from "@maple/views/charts/theme-colors" import { isReady, Message, type Model } from "./chart-bench" import { CHART_TOKENS, metricsGridOf } from "../../page/kit/service-metrics/grid" // `/lab/bench/service-detail` and -// `/lab/bench/overview`): the service-detail chart grid over synthetic rows. +// `/lab/bench/overview`): the service-detail chart grid over synthetic rows. A drag zooms the rows. const BUCKET_COUNT = 145 const BUCKET_MS = 5 * 60 * 1000 @@ -31,6 +32,14 @@ export const SERVICE_DETAIL_POINTS: ReadonlyArray = Arr.makeBy(B } }) +const rowsIn = (zoom: Model["zoom"]): ReadonlyArray => + zoom === null + ? SERVICE_DETAIL_POINTS + : Arr.filter(SERVICE_DETAIL_POINTS, (row) => { + const at = parseBucketMs(row.bucket) + return at !== null && at >= zoom.startMs && at <= zoom.endMs + }) + /** The bench page: `MetricsGrid` with `yAxisWidth={72}` under a padded full-height frame. */ export const view = (model: Model, h: HtmlBuilder): Html => h.div( @@ -44,10 +53,10 @@ export const view = (model: Model, h: HtmlBuilder): Html => h, { charts: model.charts, - rows: SERVICE_DETAIL_POINTS, + rows: rowsIn(model.zoom), markers: [], toChartMessage: (chartId, message) => Message.GotChartMessage({ chartId, message }), - linkedCursorMount: MountLinkedCursor(), + linkedCursorMount: MountLinkedZoom({ timeZone: model.timeZone }), }, { isLoading: !isReady(model), diff --git a/apps/web/src/page/infra/hosts/detail/host-detail.scene.test.ts b/apps/web/src/page/infra/hosts/detail/host-detail.scene.test.ts index 4215b85755..9d775ec27f 100644 --- a/apps/web/src/page/infra/hosts/detail/host-detail.scene.test.ts +++ b/apps/web/src/page/infra/hosts/detail/host-detail.scene.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "vitest" import { PageOutMessage, type PageContext } from "../../../contract" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import * as Frame from "../../../kit/time-frame" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { Message } from "./message" import type { HostSummary, Model } from "./model" import { init, update } from "./update" @@ -38,7 +38,7 @@ const summary: NonNullable = { /** The page root reads theme colours and the metrics panel wires the linked cursor. */ const mounted = () => [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), - Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()), ] const resolved = (model: Model, at = window) => diff --git a/apps/web/src/page/infra/hosts/detail/message.ts b/apps/web/src/page/infra/hosts/detail/message.ts index 78f13baaa7..4e5b453b45 100644 --- a/apps/web/src/page/infra/hosts/detail/message.ts +++ b/apps/web/src/page/infra/hosts/detail/message.ts @@ -18,6 +18,7 @@ export const Message = defineMessageUnion({ ClickedWidenRange: {}, ...ChartEnvMessages, CompletedMountLinkedCursor: {}, + SelectedChartRange: { startMs: Schema.Number, endMs: Schema.Number }, GotChartMessage: { metric: HostMetric, message: Chart.Message }, GotCopyMessage: { label: Schema.String, message: CopyButton.Message }, GotFrameMessage: { message: Frame.Message }, diff --git a/apps/web/src/page/infra/hosts/detail/update.ts b/apps/web/src/page/infra/hosts/detail/update.ts index bafa104abb..c94ca7ef5b 100644 --- a/apps/web/src/page/infra/hosts/detail/update.ts +++ b/apps/web/src/page/infra/hosts/detail/update.ts @@ -1,3 +1,4 @@ +import { zoomSearchPatch } from "../../../kit/chart-zoom" import { chartEnvHandlers } from "@maple/views/charts/chart-env" import { bucketSecondsForRange } from "../../kit/constants" import * as Chart from "@maple/views/charts/chart" @@ -136,6 +137,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ...chartEnvHandlers(model), CompletedMountLinkedCursor: () => ({ model }), + SelectedChartRange: (range) => navigate(model, { ...model.search, ...zoomSearchPatch(range) }), GotChartMessage: ({ metric, message: inner }) => { const chart = model.charts[metric] ?? Chart.init() return { diff --git a/apps/web/src/page/infra/hosts/detail/view-panels.ts b/apps/web/src/page/infra/hosts/detail/view-panels.ts index f33d21171c..080d7b23aa 100644 --- a/apps/web/src/page/infra/hosts/detail/view-panels.ts +++ b/apps/web/src/page/infra/hosts/detail/view-panels.ts @@ -1,3 +1,4 @@ +import { cn } from "@maple/ui/lib/utils" import { countLabel } from "@maple/ui/lib/format" import { relativeTime } from "../../../kit/relative-time" import { AsyncData } from "foldkit" @@ -6,7 +7,7 @@ import * as Chart from "@maple/views/charts/chart" import { panel, panelHeader } from "@maple/views/ui/panel" import { middleTruncate } from "@maple/views/ui/middle-truncate" import { formatValueWithUnit } from "@maple/views/lib/infra-chart-units" -import { LINKED_CURSOR_STYLE, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LINKED_CURSOR_STYLE, LINKED_ZOOM_CLASS, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { INFRA_METRIC_CHART_HEIGHT, type InfraSeriesInfo, @@ -153,7 +154,11 @@ export const metricsPanel = (h: H, model: Model, timeZone: string): Html => ]), }), h.div( - [h.Class("px-4"), h.Style(LINKED_CURSOR_STYLE), h.OnMount(MountLinkedCursor())], + [ + h.Class(cn("px-4", LINKED_ZOOM_CLASS)), + h.Style(LINKED_CURSOR_STYLE), + h.OnMount(MountLinkedZoom({ timeZone })), + ], Arr.map(METRIC_STRIPS, (strip) => metricStrip(h, model, strip, timeZone)), ), ]) diff --git a/apps/web/src/page/kit/chart-zoom.test.ts b/apps/web/src/page/kit/chart-zoom.test.ts new file mode 100644 index 0000000000..12c295abed --- /dev/null +++ b/apps/web/src/page/kit/chart-zoom.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest" +import { zoomSearchPatch } from "./chart-zoom" + +describe("zoomSearchPatch", () => { + test("a dragged span becomes whole-second bounds that contain it, and drops the preset", () => { + expect( + zoomSearchPatch({ + startMs: Date.parse("2026-10-01T11:00:00.400Z"), + endMs: Date.parse("2026-10-01T11:30:00.200Z"), + }), + ).toEqual({ + startTime: "2026-10-01 11:00:00", + endTime: "2026-10-01 11:30:01", + timePreset: undefined, + }) + }) + + test("a sliver of a drag still opens a minute", () => { + const at = Date.parse("2026-10-01T11:00:00Z") + expect(zoomSearchPatch({ startMs: at, endMs: at + 4_000 })).toEqual({ + startTime: "2026-10-01 11:00:00", + endTime: "2026-10-01 11:01:00", + timePreset: undefined, + }) + }) +}) diff --git a/apps/web/src/page/kit/chart-zoom.ts b/apps/web/src/page/kit/chart-zoom.ts new file mode 100644 index 0000000000..6cdecdeb90 --- /dev/null +++ b/apps/web/src/page/kit/chart-zoom.ts @@ -0,0 +1,16 @@ +import { formatWarehouseDateTime } from "@maple/query-engine" +import type { ChartRange } from "@maple/views/charts/linked-cursor" +import type { TimeSearchPatch } from "./time-range-picker" + +const MIN_ZOOM_MS = 60_000 + +/** A span dragged across a chart as the URL's absolute window: whole seconds, at least a minute, no preset. */ +export const zoomSearchPatch = (range: ChartRange): TimeSearchPatch => { + const startMs = Math.floor(range.startMs / 1000) * 1000 + const endMs = Math.max(Math.ceil(range.endMs / 1000) * 1000, startMs + MIN_ZOOM_MS) + return { + startTime: formatWarehouseDateTime(startMs), + endTime: formatWarehouseDateTime(endMs), + timePreset: undefined, + } +} diff --git a/apps/web/src/page/kit/service-metrics/grid.ts b/apps/web/src/page/kit/service-metrics/grid.ts index 0bf6066a59..9403bea5d1 100644 --- a/apps/web/src/page/kit/service-metrics/grid.ts +++ b/apps/web/src/page/kit/service-metrics/grid.ts @@ -16,7 +16,11 @@ import { import { resolveColor, type ThemeColors } from "@maple/views/charts/theme-colors" import { widgetShell } from "../widget-shell" import { type commitMarkers, markerLayout } from "./markers" -import { LINKED_CURSOR_STYLE, linkedCursorOverlay } from "@maple/views/charts/linked-cursor" +import { + LINKED_CURSOR_STYLE, + LINKED_ZOOM_CLASS, + linkedCursorOverlay, +} from "@maple/views/charts/linked-cursor" import { markersOverlay } from "./markers-view" import { chartFrameClass } from "@maple/views/ui/chart-frame" @@ -155,7 +159,7 @@ export type GridInput = Readonly<{ rows: ReadonlyArray markers: ReturnType toChartMessage: (chartId: string, message: Chart.Message) => M - /** `h.OnMount(MountLinkedCursor())`, lifted to the page's Message. */ + /** `h.OnMount(MountLinkedZoom(...))`, lifted to the page's Message. */ linkedCursorMount: Parameters["OnMount"]>[0] }> @@ -205,7 +209,13 @@ export const metricsGridOf = (h: HtmlBuilder, input: GridInput, state: h.DataAttribute("metrics-grid", ""), h.OnMount(input.linkedCursorMount), ...(state.isWaiting ? [h.AriaBusy(true)] : []), - h.Class(cn("grid grid-cols-1 md:grid-cols-2 gap-3", refreshingClass(state.isWaiting))), + h.Class( + cn( + "grid grid-cols-1 md:grid-cols-2 gap-3", + refreshingClass(state.isWaiting), + LINKED_ZOOM_CLASS, + ), + ), h.Style(LINKED_CURSOR_STYLE), ], Arr.map(CHARTS, (chart) => { diff --git a/apps/web/src/page/overview/analyst/analyst.scene.test.ts b/apps/web/src/page/overview/analyst/analyst.scene.test.ts index d08238198b..04f63ffcb3 100644 --- a/apps/web/src/page/overview/analyst/analyst.scene.test.ts +++ b/apps/web/src/page/overview/analyst/analyst.scene.test.ts @@ -11,7 +11,7 @@ import { withViewInputs, } from "foldkit/scene" import { describe, expect, test } from "vitest" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { buildErrorsLabFixture } from "../../../lab/errors-fixture" import { @@ -332,7 +332,7 @@ describe("overview, analyst", () => { }, given(entered.model), Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), - Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()), expectView(role("heading", { level: 1, name: "Overview" })).toExist(), expectView(text("Open incidents")).toBeAbsent(), expectView(text("Top errors")).toBeAbsent(), diff --git a/apps/web/src/page/overview/message.ts b/apps/web/src/page/overview/message.ts index 3474631ecd..ae1df034c5 100644 --- a/apps/web/src/page/overview/message.ts +++ b/apps/web/src/page/overview/message.ts @@ -23,6 +23,7 @@ const result = (schema: A) => ({ export const Message = defineMessageUnion({ CompletedMountLinkedCursor: {}, + SelectedChartRange: { startMs: Schema.Number, endMs: Schema.Number }, CompletedReadEntry: { nowMs: Schema.Number, hint: FacetsHint }, CompletedResolveClock: { nowMs: Schema.Number }, CompletedFetchFacets: result(OverviewFacets), diff --git a/apps/web/src/page/overview/overview.scene.test.ts b/apps/web/src/page/overview/overview.scene.test.ts index 5c952b7bfa..884e498c06 100644 --- a/apps/web/src/page/overview/overview.scene.test.ts +++ b/apps/web/src/page/overview/overview.scene.test.ts @@ -14,7 +14,7 @@ import { withViewInputs, } from "foldkit/scene" import { describe, expect, test } from "vitest" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { toLoadFailure } from "@maple/views/data/load-failure" import { PageOutMessage, type PageContext } from "../contract" @@ -90,7 +90,7 @@ const withPresence = (model: Model, traces: Presence, logs: Presence = traces): const themeResolved = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) -const cursorMounted = () => Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()) +const cursorMounted = () => Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()) describe("overview", () => { test("entry reads the clock and hint; downstream queries wait for facets", () => { @@ -253,7 +253,7 @@ describe("overview", () => { given(failed), Mount.resolveAll( [ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })], - [MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()], + [MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()], ), expectView(text("Firing now:")).toBeAbsent(), ) @@ -271,7 +271,7 @@ describe("overview", () => { ] const mounts = Mount.resolveAll( [ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })], - [MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()], + [MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()], ) scene( { update: pageUpdate, view }, @@ -504,3 +504,21 @@ describe("overview", () => { expect(settled.model.isReloading).toBe(false) }) }) + +describe("chart zoom", () => { + test("a drag across an Overview chart opens that span as the window", () => { + const zoomed = pageUpdate( + loaded([snapshotRow("checkout")]), + Message.SelectedChartRange({ + startMs: Date.parse("2026-10-01T09:00:00Z"), + endMs: Date.parse("2026-10-01T09:45:00Z"), + }), + ) + expect(zoomed.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/?startTime=2026-10-01+09%3A00%3A00&endTime=2026-10-01+09%3A45%3A00", + isReplace: false, + }), + ) + }) +}) diff --git a/apps/web/src/page/overview/setup-checklist.scene.test.ts b/apps/web/src/page/overview/setup-checklist.scene.test.ts index dd1a4d4199..336dc93e69 100644 --- a/apps/web/src/page/overview/setup-checklist.scene.test.ts +++ b/apps/web/src/page/overview/setup-checklist.scene.test.ts @@ -2,7 +2,7 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { toLoadFailure } from "@maple/views/data/load-failure" import { failureSentence } from "@maple/views/ui/error-state" @@ -77,7 +77,7 @@ const realServices = (model: Model) => (model.setup === null ? [] : realServices const RECT = { left: 0, right: 225, top: 4, bottom: 5, width: 112, height: 32 } const pageMounts = [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), - Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()), ] as const /** The guided setup's two underline tab lists (snippets and package managers). */ const tabMounts = [ diff --git a/apps/web/src/page/overview/update.ts b/apps/web/src/page/overview/update.ts index a55252d70a..59124361eb 100644 --- a/apps/web/src/page/overview/update.ts +++ b/apps/web/src/page/overview/update.ts @@ -1,3 +1,4 @@ +import { zoomSearchPatch } from "../kit/chart-zoom" import { pickerStep } from "../kit/page-time" import { keyed as keyedStep, runSteps, type Step as KitStep } from "../kit/keyed-step" import { chartEnvHandlers } from "@maple/views/charts/chart-env" @@ -371,9 +372,7 @@ export const init = initWith(isClerkAuthEnabled) /** Back after leaving: the last data stays on screen while entry re-reads the clock (snapped, so cached reads serve). */ export const revive = (model: Model, route: { readonly search: Search }, context: PageContext): Return => { if (context.audience === "analyst") - return withAnalyst({ ...model, search: route.search }, (analyst) => - Analyst.revive(analyst, context), - ) + return withAnalyst({ ...model, search: route.search }, (analyst) => Analyst.revive(analyst, context)) // The open-anomalies key never changes, so entry alone would keep the parked counts. const anomalies = Keyed.request( Keyed.forgetFailure(model.anomalies), @@ -513,6 +512,7 @@ const withChart = (model: Model, chartId: string, message: Chart.Message): Retur export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { CompletedMountLinkedCursor: () => ({ model }), + SelectedChartRange: (range) => navigate(model, { ...model.search, ...zoomSearchPatch(range) }), CompletedReadEntry: ({ nowMs, hint }) => resolveSetupWindow( sync({ ...model, nowMs, hint, facetsWindow: facetsWindowAt(nowMs) }, context, "Load"), diff --git a/apps/web/src/page/overview/view-metrics.scene.test.ts b/apps/web/src/page/overview/view-metrics.scene.test.ts index d1b02c1621..42a5271695 100644 --- a/apps/web/src/page/overview/view-metrics.scene.test.ts +++ b/apps/web/src/page/overview/view-metrics.scene.test.ts @@ -18,7 +18,7 @@ import { describe, expect, test } from "vitest" import * as Chart from "@maple/views/charts/chart" import { MountChart, PortalTooltipBody } from "@maple/views/charts/chart-mounts" import { CHART_SPEC_ATTRIBUTE, decodeChartSpec } from "@maple/views/charts/chart-spec" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import type { PageContext } from "../contract" import * as TimeRangePicker from "../kit/time-range-picker" import { Message } from "./message" @@ -74,7 +74,7 @@ const view = (model: Model, h: HtmlBuilder): Html => const pageUpdate = (model: Model, message: Message) => ({ model: update(model, message, context).model }) const mounted = () => Mount.resolveAll( - [MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()], + [MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()], [MountChart, Chart.Message.CompletedMountChart()], [MountChart, Chart.Message.CompletedMountChart()], [MountChart, Chart.Message.CompletedMountChart()], diff --git a/apps/web/src/page/overview/view-metrics.ts b/apps/web/src/page/overview/view-metrics.ts index 7a88afaa60..544f83838c 100644 --- a/apps/web/src/page/overview/view-metrics.ts +++ b/apps/web/src/page/overview/view-metrics.ts @@ -12,8 +12,10 @@ import * as FixedMetricChart from "@maple/views/charts/fixed-metric-chart" import type { FixedMetricChartInputs } from "@maple/views/charts/fixed-metric-chart" import { LINKED_CURSOR_STYLE, + LINKED_ZOOM_CLASS, + LinkedCursorMessage, linkedCursorOverlay, - MountLinkedCursor, + MountLinkedZoom, } from "@maple/views/charts/linked-cursor" import { fixedMetricModel, @@ -413,9 +415,23 @@ export const metricsGrid = (h: HtmlBuilder, model: Model, context: Grid [ h.DataAttribute("metrics-grid", ""), ...(isWaiting ? [h.AriaBusy(true)] : []), - h.Class(cn("grid grid-cols-1 md:grid-cols-2 gap-3", refreshingClass(isWaiting), "mt-4")), + h.Class( + cn( + "grid grid-cols-1 md:grid-cols-2 gap-3", + refreshingClass(isWaiting), + "mt-4", + LINKED_ZOOM_CLASS, + ), + ), h.Style(LINKED_CURSOR_STYLE), - h.OnMount(Mount.mapMessage(MountLinkedCursor(), () => Message.CompletedMountLinkedCursor())), + h.OnMount( + Mount.mapMessage(MountLinkedZoom({ timeZone: context.timeZone }), (message) => + LinkedCursorMessage.match(message, { + CompletedMountLinkedCursor: () => Message.CompletedMountLinkedCursor(), + SelectedChartRange: (range) => Message.SelectedChartRange(range), + }), + ), + ), ], Arr.map(CARD_IDS, (id) => cardSlot(id, metricCard, [h, id, data, gridContext, model.charts[id] ?? EMPTY_CHART]), diff --git a/apps/web/src/page/releases/detail/message.ts b/apps/web/src/page/releases/detail/message.ts index ed095f6c6c..8b554bab5a 100644 --- a/apps/web/src/page/releases/detail/message.ts +++ b/apps/web/src/page/releases/detail/message.ts @@ -36,6 +36,7 @@ export const Message = defineMessageUnion({ GotChartMessage: { chartId: Schema.String, message: Chart.Message }, ...ChartEnvMessages, CompletedMountLinkedCursor: {}, + SelectedChartRange: { startMs: Schema.Number, endMs: Schema.Number }, GotOverlaysMessage: { message: Overlays.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/releases/detail/release.scene.test.ts b/apps/web/src/page/releases/detail/release.scene.test.ts index c9f9b8478a..b8031d7f9f 100644 --- a/apps/web/src/page/releases/detail/release.scene.test.ts +++ b/apps/web/src/page/releases/detail/release.scene.test.ts @@ -2,7 +2,7 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr, String as Str } from "effect" import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import type { PageContext } from "../../contract" import { toLoadFailure } from "@maple/views/data/load-failure" @@ -64,7 +64,7 @@ const withReleases = (commitSha = HEAD) => { const FAILURE = toLoadFailure("injected") const THEME = Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) -const CURSOR = Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()) +const CURSOR = Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()) const point = (bucket: string) => ({ bucket, throughput: 10, diff --git a/apps/web/src/page/releases/detail/update.ts b/apps/web/src/page/releases/detail/update.ts index fe6d5c3de8..8950c1ee5f 100644 --- a/apps/web/src/page/releases/detail/update.ts +++ b/apps/web/src/page/releases/detail/update.ts @@ -1,3 +1,4 @@ +import { zoomSearchPatch } from "../../kit/chart-zoom" import { pickerStep } from "../../kit/page-time" import { chartEnvHandlers } from "@maple/views/charts/chart-env" import type { RequestMode } from "@maple/views/data/keyed" @@ -247,6 +248,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, ...chartEnvHandlers(model), CompletedMountLinkedCursor: () => ({ model }), + SelectedChartRange: (range) => navigate(model, { ...model.search, ...zoomSearchPatch(range) }), ClickedReload: () => { const reloaded = { ...model, refreshVersion: model.refreshVersion + 1, isReloading: true } return { diff --git a/apps/web/src/page/releases/detail/view-service.ts b/apps/web/src/page/releases/detail/view-service.ts index 74572722e7..e2413b6e02 100644 --- a/apps/web/src/page/releases/detail/view-service.ts +++ b/apps/web/src/page/releases/detail/view-service.ts @@ -7,7 +7,7 @@ import { Option, Array as Arr } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { isChartRuntimeReady } from "@maple/views/charts/chart-runtime" -import { MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors } from "@maple/views/charts/theme-colors" import { AppRoute, hrefOf } from "../../../route" import { linkButton } from "@maple/views/ui/button" @@ -353,7 +353,7 @@ const loadedBody = (h: H, model: Model, derived: ServiceDerived, serviceName: st rows: Arr.map(seriesPoints(model, derived), (point) => ({ ...point })), markers: markersOf(model, derived), toChartMessage: (chartId, message) => Message.GotChartMessage({ chartId, message }), - linkedCursorMount: MountLinkedCursor(), + linkedCursorMount: MountLinkedZoom({ timeZone: clock.timeZone }), }, { isLoading: !isChartReady, diff --git a/apps/web/src/page/services/detail/detail.scene.test.ts b/apps/web/src/page/services/detail/detail.scene.test.ts index a27d39dc91..0bbe374770 100644 --- a/apps/web/src/page/services/detail/detail.scene.test.ts +++ b/apps/web/src/page/services/detail/detail.scene.test.ts @@ -29,7 +29,7 @@ import { FetchRuntimes, FetchWorkloads, } from "./command" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import * as Dialog from "@maple/views/ui/dialog" import * as PeekMessage from "./endpoint-peek/message" import { Message } from "./message" @@ -434,7 +434,7 @@ describe("service detail view", () => { { update: pageUpdate, view }, given(loaded), themeResolved(), - Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()), expectView(text("10 versions")).toExist(), expectView(text("v1.0.1")).not.toExist(), click(role("button", { name: "Show all 10 deploys" })), @@ -499,7 +499,7 @@ describe("where the service runs", () => { const RUN_HREF = "/infra/gcp/cloudRun/payment-svc?project=acme-prod®ion=europe-west1" const settled = [ themeResolved(), - Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()), ] test("a service on Cloud Run lists it under Runs on, linked to its Google Cloud page", () => { @@ -702,3 +702,21 @@ describe("the API tab's endpoint drawer", () => { ) }) }) + +describe("chart zoom", () => { + test("a drag across a chart opens that span as the window, as a push so Back returns", () => { + const zoomed = pageUpdate( + entered({ timePreset: "24h", tab: "operations" }), + Message.SelectedChartRange({ + startMs: Date.parse("2026-10-01T09:00:00Z"), + endMs: Date.parse("2026-10-01T09:45:00Z"), + }), + ) + expect(zoomed.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/services/payment-svc?tab=operations&startTime=2026-10-01+09%3A00%3A00&endTime=2026-10-01+09%3A45%3A00", + isReplace: false, + }), + ) + }) +}) diff --git a/apps/web/src/page/services/detail/message.ts b/apps/web/src/page/services/detail/message.ts index 7975504037..f0d199d820 100644 --- a/apps/web/src/page/services/detail/message.ts +++ b/apps/web/src/page/services/detail/message.ts @@ -61,6 +61,7 @@ export const Message = defineMessageUnion({ CompletedFetchDependencies: result(DependencyBundle), CompletedFetchCommit: { sha: Schema.String, result: Schema.Result(Commit, CommitFailure) }, CompletedMountLinkedCursor: {}, + SelectedChartRange: { startMs: Schema.Number, endMs: Schema.Number }, ...ChartEnvMessages, ClickedReload: {}, CompletedReloadSpin: { version: Schema.Number }, diff --git a/apps/web/src/page/services/detail/update.ts b/apps/web/src/page/services/detail/update.ts index 8f588e33d6..d15a00c1ea 100644 --- a/apps/web/src/page/services/detail/update.ts +++ b/apps/web/src/page/services/detail/update.ts @@ -1,3 +1,4 @@ +import { zoomSearchPatch } from "../../kit/chart-zoom" import { pickerStep } from "../../kit/page-time" import { keyed as keyedStep, runSteps, type Step as KitStep } from "../../kit/keyed-step" import { chartEnvHandlers } from "@maple/views/charts/chart-env" @@ -809,6 +810,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), CompletedFetchCommit: ({ sha, result }) => ({ model: settleCommit(model, sha, result) }), CompletedMountLinkedCursor: () => ({ model }), + // A push, so Back returns to the window the drag started in. + SelectedChartRange: (range) => navigate(model, { ...model.search, ...zoomSearchPatch(range) }, false), ...chartEnvHandlers(model), ClickedReload: () => { const reloaded = { ...model, refreshVersion: model.refreshVersion + 1, isReloading: true } diff --git a/apps/web/src/page/services/detail/uptime-card.scene.test.ts b/apps/web/src/page/services/detail/uptime-card.scene.test.ts index 1cb3e95c0d..e08f8a6ee5 100644 --- a/apps/web/src/page/services/detail/uptime-card.scene.test.ts +++ b/apps/web/src/page/services/detail/uptime-card.scene.test.ts @@ -1,7 +1,7 @@ import { OrgId } from "@maple/domain" import { Result, Schema } from "effect" import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { describe, test } from "vitest" import type { UptimeMonitor } from "../../kit/uptime/derive" @@ -19,7 +19,7 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const mounted = [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), - Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()), ] as const /** The Overview of `checkout-api` with the org's monitors answered. */ diff --git a/apps/web/src/page/services/detail/view-charts.ts b/apps/web/src/page/services/detail/view-charts.ts index 8b39fd4749..6136c4cb5c 100644 --- a/apps/web/src/page/services/detail/view-charts.ts +++ b/apps/web/src/page/services/detail/view-charts.ts @@ -2,7 +2,7 @@ import { mergeExactThroughput } from "@maple/web-data/warehouse/custom-charts" import { Option, Array as Arr } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { type GridState, metricsGridOf } from "../../kit/service-metrics/grid" import { commitMarkers } from "../../kit/service-metrics/markers" import type { DetailPoint } from "../../kit/service-metrics/points" @@ -21,7 +21,12 @@ export const mergedPoints = (model: Model): ReadonlyArray => { return mergeExactThroughput(base, new Map(Arr.map(exact, (point) => [point.bucket, point.throughput]))) } -export const metricsGrid = (h: HtmlBuilder, model: Model, state: GridState): Html => { +export const metricsGrid = ( + h: HtmlBuilder, + model: Model, + state: GridState, + timeZone: string, +): Html => { const points = mergedPoints(model) const overview = AsyncData.getData(model.overview.data) const markers = Option.match(overview, { @@ -40,7 +45,7 @@ export const metricsGrid = (h: HtmlBuilder, model: Model, state: GridSt rows: Arr.map(points, (point) => ({ ...point })), markers, toChartMessage: (chartId, message) => Message.GotChartMessage({ chartId, message }), - linkedCursorMount: MountLinkedCursor(), + linkedCursorMount: MountLinkedZoom({ timeZone }), }, state, ) diff --git a/apps/web/src/page/services/detail/view-overview.ts b/apps/web/src/page/services/detail/view-overview.ts index f42f5d9167..524b59af6b 100644 --- a/apps/web/src/page/services/detail/view-overview.ts +++ b/apps/web/src/page/services/detail/view-overview.ts @@ -44,14 +44,19 @@ export const overviewTab = (h: H, model: Model, context: PageContext): Html => { ISSUES_DETAIL_LIMIT_MS const clock = { nowMs, timeZone: context.timeZone } return vstack(h, { gap: "3" }, [ - metricsGrid(h, model, { - isLoading: isDetailLoading || !isChartReady, - isWaiting: AsyncData.isRefreshing(overview), - context: - model.themeColors === null - ? null - : { theme: model.themeColors, timeZone: context.timeZone, nowMs }, - }), + metricsGrid( + h, + model, + { + isLoading: isDetailLoading || !isChartReady, + isWaiting: AsyncData.isRefreshing(overview), + context: + model.themeColors === null + ? null + : { theme: model.themeColors, timeZone: context.timeZone, nowMs }, + }, + context.timeZone, + ), topOperationsPanel(h, model, model.themeColors), grid(h, { lg: 2, gap: "3" }, [ issuesPanel(h, model, clock, isLimited), diff --git a/apps/web/src/update.audience.test.ts b/apps/web/src/update.audience.test.ts index 6e9ad84aa0..5a7d184f4b 100644 --- a/apps/web/src/update.audience.test.ts +++ b/apps/web/src/update.audience.test.ts @@ -19,7 +19,7 @@ import { pageContextOf, settle, shellClerkOf, update } from "./update" import { view } from "./view" import type { HtmlBuilder } from "foldkit/html" import { expect as expectView, given, Mount, scene, text } from "foldkit/scene" -import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { LinkedCursorMessage, MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { MountAnalystHome } from "./page/overview/analyst/command" import { Message as AnalystMessage } from "./page/overview/analyst/message" @@ -109,7 +109,7 @@ describe("the Overview the root draws", () => { entered("org:admin"), ...shellMounted, Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), - Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + Mount.resolve(MountLinkedZoom, LinkedCursorMessage.CompletedMountLinkedCursor()), expectView(text("Open incidents")).toBeAbsent(), ) }) diff --git a/packages/views/src/charts/chart-mounts.ts b/packages/views/src/charts/chart-mounts.ts index 5c374b79cb..4dc9d202d1 100644 --- a/packages/views/src/charts/chart-mounts.ts +++ b/packages/views/src/charts/chart-mounts.ts @@ -4,6 +4,7 @@ import { Message } from "./chart" import type { ChartBounds } from "@tanstack/charts" import type { ChartHost, CreateChartHost, TimeAxis } from "./chart-host-base" import { CHART_LABEL_ATTRIBUTE, CHART_SPEC_ATTRIBUTE, decodeChartSpec } from "./chart-spec" +import { encodePlotTimeAxis, PLOT_TIME_AXIS_ATTRIBUTE } from "./plot-time-axis" /** `PlotFrame`'s `FALLBACK_HEIGHT`: drawn at until the plot box has been measured. */ const FALLBACK_HEIGHT = 320 @@ -99,6 +100,8 @@ export const MountChart = Mount.defineStream("MountChart", { // The last geometry sent: `onRender` runs on every pointer tick, a Message only on change. const sent = { key: "" } + // The time axis last written on the anchor, for the same reason. + const axisSent = { value: "" } // A chart without a time x scale (a pie) reports its rect with an empty domain. const NO_AXIS = { domainStartMs: 0, @@ -155,6 +158,22 @@ export const MountChart = Mount.defineStream("MountChart", { anchor.style.transform = `translate(${bounds.x}px, ${bounds.y}px)` anchor.style.width = `${bounds.width}px` anchor.style.height = `${bounds.height}px` + const encoded = Option.match(timeAxis, { + onNone: () => "", + onSome: (axis) => + encodePlotTimeAxis({ + domainStartMs: axis.domainStartMs, + domainEndMs: axis.domainEndMs, + startPx: axis.rangeStart - bounds.x, + endPx: axis.rangeEnd - bounds.x, + }), + }) + if (encoded === axisSent.value) return + axisSent.value = encoded + if (encoded === "") + anchor.removeAttribute(PLOT_TIME_AXIS_ATTRIBUTE) + else + anchor.setAttribute(PLOT_TIME_AXIS_ATTRIBUTE, encoded) }, }, }), diff --git a/packages/views/src/charts/linked-cursor.test.ts b/packages/views/src/charts/linked-cursor.test.ts new file mode 100644 index 0000000000..e21cd1f734 --- /dev/null +++ b/packages/views/src/charts/linked-cursor.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest" +import { draggedRange, zoomLabel } from "./linked-cursor" + +const HOUR = 3_600_000 +const axis = { domainStartMs: 0, domainEndMs: 10 * HOUR, startPx: 0, endPx: 500 } +const clock = (ms: number, withDay: boolean) => `${withDay ? "day " : ""}${ms / HOUR}h` + +describe("linked zoom", () => { + test("a drag reads as the same span whichever way it was dragged", () => { + const leftToRight = draggedRange(axis, 200, 300, 450) + expect(leftToRight).toEqual({ startMs: 2 * HOUR, endMs: 5 * HOUR }) + expect(draggedRange(axis, 200, 450, 300)).toEqual(leftToRight) + }) + + test("a drag that leaves the plot ends at the axis, not past it", () => { + expect(draggedRange(axis, 200, 600, 2_000)).toEqual({ startMs: 8 * HOUR, endMs: 10 * HOUR }) + }) + + test("the label gives both ends and the length, with the day once the span is half a day", () => { + expect([ + zoomLabel({ startMs: 2 * HOUR, endMs: 5 * HOUR }, clock), + zoomLabel({ startMs: 0, endMs: 30 * HOUR }, clock), + ]).toEqual(["2h to 5h · 3h", "day 0h to day 30h · 1d 6h"]) + }) +}) diff --git a/packages/views/src/charts/linked-cursor.ts b/packages/views/src/charts/linked-cursor.ts index 5a23a37b62..bb91c772cd 100644 --- a/packages/views/src/charts/linked-cursor.ts +++ b/packages/views/src/charts/linked-cursor.ts @@ -1,29 +1,113 @@ -import { Effect, Queue, Stream, Array as Arr } from "effect" +import { formatDuration } from "@maple/ui/lib/format" +import { getBrowserTimeZone, isValidIanaTimeZone } from "@maple/ui/lib/timezone" +import { Effect, Option, Queue, Schema, Stream, Array as Arr } from "effect" import { Mount } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { badge } from "../ui/display" +import { + decodePlotTimeAxis, + PLOT_TIME_AXIS_ATTRIBUTE, + type PlotTimeAxis, + timeAtPlotX, +} from "./plot-time-axis" // The linked cursor: hovering one chart paints a cursor at the same x ratio on every sibling, // through CSS variables written imperatively on the overlays. Never on the container: the canvas // renderer rebuilds its whole scene when any ancestor's `style` or `class` changes. +// The linked zoom rides on the same listeners: a drag across a time chart paints a band on every +// sibling and, on release, reports the span it covered. /** The container's resting cursor variables. */ export const LINKED_CURSOR_STYLE = { "--linked-cursor-ratio": "0", "--linked-cursor-visible": "0" } +/** On a zooming group's container: the crosshair that says its charts can be dragged. */ +export const LINKED_ZOOM_CLASS = "[&_.ts-chart-surface]:cursor-crosshair" + const CHART_SELECTOR = "[data-linked-cursor-chart]" const OVERLAY_SELECTOR = "[data-linked-cursor-overlay]" +const LINE_SELECTOR = "[data-linked-cursor-line]" +const LABEL_SELECTOR = "[data-linked-zoom-label]" const PLOT_SELECTOR = "[data-chart-plot], .recharts-cartesian-grid" +const SURFACE_SELECTOR = ".ts-chart-surface" +/** A press on a control, or on what a page lays over the plot (deploy markers), is not a drag. */ +const PRESSABLE_SELECTOR = "a, button, [role='button'], input, select, textarea, [data-chart-overlay]" const TOOLTIP_UPDATE_INTERVAL_MS = 1000 / 30 +/** A press that moves less than this is a click, not a drag. */ +const DRAG_THRESHOLD_PX = 5 +const DAY_MS = 86_400_000 +/** A click this soon after a drag's release is the drag's own. */ +const CLICK_AFTER_DRAG_MS = 50 -export const LinkedCursorMessage = defineMessageUnion({ CompletedMountLinkedCursor: {} }) +export const LinkedCursorMessage = defineMessageUnion({ + CompletedMountLinkedCursor: {}, + /** A drag across a time chart ended on this span, in epoch ms. */ + SelectedChartRange: { startMs: Schema.Number, endMs: Schema.Number }, +}) export type LinkedCursorMessage = typeof LinkedCursorMessage.Type -/** The hovered chart, the last tooltip-forwarding move, the overlays placed on their plots (null until aligned), and whether they show. */ +export interface ChartRange { + readonly startMs: number + readonly endMs: number +} + +/** What makes a cursor group zoom: the clock its drag label reads in, and the Message a finished drag becomes. */ +export interface LinkedZoom { + readonly timeZone: string + readonly selected: (range: ChartRange) => A +} + +/** One drag: the chart it started on, that plot's left edge and width (client px), and its time axis. */ +interface Drag { + readonly chart: HTMLElement + readonly left: number + readonly width: number + readonly axis: PlotTimeAxis + readonly fromX: number + moved: boolean +} + +/** The hovered chart, the last tooltip-forwarding move, the overlays placed on their plots (null until aligned), whether they show, and the drag in flight. */ interface CursorState { active: HTMLElement | null lastTooltip: number overlays: ReadonlyArray | null visible: boolean + drag: Drag | null + /** When the last drag was released (an event timestamp), to tell its trailing click from a real one. */ + dragEndedAt: number +} + +/** The span between two client x positions on a plot whose left edge is `plotLeft`, in order. */ +export const draggedRange = ( + axis: PlotTimeAxis, + plotLeft: number, + fromX: number, + toX: number, +): ChartRange => { + const from = timeAtPlotX(axis, fromX - plotLeft) + const to = timeAtPlotX(axis, toX - plotLeft) + return { startMs: Math.min(from, to), endMs: Math.max(from, to) } +} + +/** The drag's label: both ends on the clock, then how long the span is. */ +export const zoomLabel = (range: ChartRange, clock: (ms: number, withDay: boolean) => string): string => { + const span = range.endMs - range.startMs + const withDay = span >= DAY_MS / 2 + return `${clock(range.startMs, withDay)} to ${clock(range.endMs, withDay)} · ${formatDuration(span)}` +} + +const clockOf = (timeZone: string) => { + const zone = isValidIanaTimeZone(timeZone) ? timeZone : getBrowserTimeZone() + const time = new Intl.DateTimeFormat(undefined, { timeZone: zone, hour: "numeric", minute: "2-digit" }) + const dayTime = new Intl.DateTimeFormat(undefined, { + timeZone: zone, + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) + return (ms: number, withDay: boolean) => (withDay ? dayTime : time).format(ms) } const alignOverlays = (container: HTMLElement) => { @@ -54,15 +138,31 @@ const alignOverlays = (container: HTMLElement) => { return Arr.map(placements, ({ overlay }) => overlay) } -/** Pointer tracking on the container; emits `attached` once listening. A page with its own Message wraps this in its Mount. */ -export const linkedCursorStream = (element: Element, attached: A): Stream.Stream => +const ratioOf = (x: number, left: number, width: number) => Math.min(1, Math.max(0, (x - left) / width)) + +/** + * Pointer tracking on the container; emits `attached` once listening. A page with its own Message + * wraps this in its Mount. With `zoom`, a drag across a time chart also emits `zoom.selected`. + */ +export const linkedCursorStream = ( + element: Element, + attached: A, + zoom?: LinkedZoom, +): Stream.Stream => Stream.callback((queue) => Effect.acquireRelease( Effect.sync(() => { if (!(element instanceof HTMLElement)) return () => undefined const container = element // Mutable listener state lives only as long as this Mount. - const state: CursorState = { active: null, lastTooltip: 0, overlays: null, visible: false } + const state: CursorState = { + active: null, + lastTooltip: 0, + overlays: null, + visible: false, + drag: null, + dragEndedAt: Number.NEGATIVE_INFINITY, + } const setVisible = (visible: boolean) => { if (state.visible === visible) return state.visible = visible @@ -70,34 +170,27 @@ export const linkedCursorStream = (element: Element, attached: A): Stream.Str overlay.style.setProperty("--linked-cursor-visible", visible ? "1" : "0"), ) } + // The hovered chart draws its own cursor, so its overlay line is hidden; its band still shows. const setSource = (next: HTMLElement | null) => { if (state.active === next) return - state.active?.querySelector(OVERLAY_SELECTOR)?.removeAttribute("hidden") + state.active?.querySelector(LINE_SELECTOR)?.removeAttribute("hidden") state.active?.removeAttribute("data-linked-cursor-source") state.active = next next?.setAttribute("data-linked-cursor-source", "") - next?.querySelector(OVERLAY_SELECTOR)?.setAttribute("hidden", "") + next?.querySelector(LINE_SELECTOR)?.setAttribute("hidden", "") } const hide = () => { setVisible(false) setSource(null) } - const onMouseMove = (event: MouseEvent) => { - const elapsed = event.timeStamp - state.lastTooltip - if (state.lastTooltip === 0 || elapsed >= TOOLTIP_UPDATE_INTERVAL_MS) { - state.lastTooltip = event.timeStamp - return - } - event.stopPropagation() - } - const onPointerMove = (event: PointerEvent) => { + const hitOf = (event: PointerEvent) => { const target = event.target const chart = target instanceof Element ? target.closest(CHART_SELECTOR) : null - const plot = chart?.querySelector(PLOT_SELECTOR) + const plot = chart?.querySelector(PLOT_SELECTOR) ?? null const bounds = plot?.getBoundingClientRect() - const isInside = - chart !== null && + return chart !== null && + plot !== null && bounds !== undefined && container.contains(chart) && bounds.width !== 0 && @@ -105,17 +198,34 @@ export const linkedCursorStream = (element: Element, attached: A): Stream.Str event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom - if (!isInside || chart === null || bounds === undefined) { + ? { chart, plot, bounds } + : null + } + const onMouseMove = (event: MouseEvent) => { + // A drag in flight holds the chart's own tooltip where it was. + if (state.drag?.moved === true) { + event.stopPropagation() + return + } + const elapsed = event.timeStamp - state.lastTooltip + if (state.lastTooltip === 0 || elapsed >= TOOLTIP_UPDATE_INTERVAL_MS) { + state.lastTooltip = event.timeStamp + return + } + event.stopPropagation() + } + const onPointerMove = (event: PointerEvent) => { + if (state.drag?.moved === true) return + const hit = hitOf(event) + if (hit === null) { hide() state.overlays = null return } const overlays = state.overlays ?? alignOverlays(container) state.overlays = overlays - const ratio = String( - Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width)), - ) - setSource(chart) + const ratio = String(ratioOf(event.clientX, hit.bounds.left, hit.bounds.width)) + setSource(hit.chart) Arr.forEach(overlays, (overlay) => overlay.style.setProperty("--linked-cursor-ratio", ratio), ) @@ -124,13 +234,20 @@ export const linkedCursorStream = (element: Element, attached: A): Stream.Str const onPointerLeave = () => { state.lastTooltip = 0 hide() - state.overlays = null + if (state.drag === null) state.overlays = null } container.addEventListener("mousemove", onMouseMove, true) container.addEventListener("pointermove", onPointerMove) container.addEventListener("pointerleave", onPointerLeave) + const stopZoom = + zoom === undefined + ? () => undefined + : listenForDrags(container, state, zoom, hitOf, setVisible, (range) => + Queue.offerUnsafe(queue, zoom.selected(range)), + ) Queue.offerUnsafe(queue, attached) return () => { + stopZoom() container.removeEventListener("mousemove", onMouseMove, true) container.removeEventListener("pointermove", onPointerMove) container.removeEventListener("pointerleave", onPointerLeave) @@ -140,25 +257,177 @@ export const linkedCursorStream = (element: Element, attached: A): Stream.Str ).pipe(Effect.flatMap(() => Effect.never)), ) +type Hit = { readonly chart: HTMLElement; readonly plot: Element; readonly bounds: DOMRect } + +/** The drag half of the stream: press on a time plot, move past the threshold, release. Returns its teardown. */ +const listenForDrags = ( + container: HTMLElement, + state: CursorState, + zoom: LinkedZoom, + hitOf: (event: PointerEvent) => Hit | null, + setCursorVisible: (visible: boolean) => void, + selected: (range: ChartRange) => void, +): (() => void) => { + const clock = clockOf(zoom.timeZone) + const paintBand = (drag: Drag, toX: number) => { + const overlays = state.overlays ?? alignOverlays(container) + state.overlays = overlays + const from = ratioOf(drag.fromX, drag.left, drag.width) + const to = ratioOf(toX, drag.left, drag.width) + Arr.forEach(overlays, (overlay) => { + overlay.style.setProperty("--linked-zoom-start", String(Math.min(from, to))) + overlay.style.setProperty("--linked-zoom-end", String(Math.max(from, to))) + overlay.style.setProperty("--linked-zoom-visible", "1") + }) + const label = drag.chart.querySelector(LABEL_SELECTOR) + if (label !== null) + label.textContent = zoomLabel(draggedRange(drag.axis, drag.left, drag.fromX, toX), clock) + } + const clearBand = (drag: Drag) => { + Arr.forEach(state.overlays ?? [], (overlay) => + overlay.style.setProperty("--linked-zoom-visible", "0"), + ) + const label = drag.chart.querySelector(LABEL_SELECTOR) + if (label !== null) label.textContent = "" + } + const cancel = () => { + const drag = state.drag + state.drag = null + if (drag?.moved === true) clearBand(drag) + } + // The click that follows a drag's release would land on whatever is under the pointer. + const swallowClick = (event: MouseEvent) => { + if (event.timeStamp - state.dragEndedAt > CLICK_AFTER_DRAG_MS) return + event.stopPropagation() + event.preventDefault() + } + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if (event.button !== 0 || event.pointerType === "touch" || !(target instanceof Element)) return + if (target.closest(PRESSABLE_SELECTOR) !== null) return + const hit = hitOf(event) + if (hit === null) return + const axis = decodePlotTimeAxis(hit.plot.getAttribute(PLOT_TIME_AXIS_ATTRIBUTE)) + if (Option.isNone(axis)) return + state.drag = { + chart: hit.chart, + left: hit.bounds.left, + width: hit.bounds.width, + axis: axis.value, + fromX: event.clientX, + moved: false, + } + } + const onDragMove = (event: PointerEvent) => { + const drag = state.drag + if (drag === null) return + if (!drag.moved) { + if (Math.abs(event.clientX - drag.fromX) < DRAG_THRESHOLD_PX) return + drag.moved = true + setCursorVisible(false) + getSelection()?.removeAllRanges() + // The chart clears its own cursor and tooltip on a leave; moves are held back until release. + drag.chart.querySelector(SURFACE_SELECTOR)?.dispatchEvent(new MouseEvent("mouseleave")) + } + paintBand(drag, event.clientX) + } + const onDragEnd = (event: PointerEvent) => { + const drag = state.drag + state.drag = null + if (drag === null || !drag.moved) return + clearBand(drag) + state.dragEndedAt = event.timeStamp + if (Math.abs(event.clientX - drag.fromX) < DRAG_THRESHOLD_PX) return + const range = draggedRange(drag.axis, drag.left, drag.fromX, event.clientX) + if (range.endMs > range.startMs) selected(range) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") cancel() + } + const onSelectStart = (event: Event) => { + if (state.drag !== null) event.preventDefault() + } + // The chart follows `pointermove` for its own cursor and tooltip: none reach it mid-drag. + const holdChart = (event: PointerEvent) => { + if (state.drag?.moved === true) event.stopPropagation() + } + container.addEventListener("pointerdown", onPointerDown) + container.addEventListener("selectstart", onSelectStart) + container.addEventListener("click", swallowClick, true) + container.addEventListener("pointermove", holdChart, true) + // Capture on the document: these run before `holdChart` stops the event. + document.addEventListener("pointermove", onDragMove, true) + document.addEventListener("pointerup", onDragEnd) + document.addEventListener("pointercancel", cancel) + document.addEventListener("keydown", onKeyDown) + return () => { + container.removeEventListener("pointerdown", onPointerDown) + container.removeEventListener("selectstart", onSelectStart) + container.removeEventListener("click", swallowClick, true) + container.removeEventListener("pointermove", holdChart, true) + document.removeEventListener("pointermove", onDragMove, true) + document.removeEventListener("pointerup", onDragEnd) + document.removeEventListener("pointercancel", cancel) + document.removeEventListener("keydown", onKeyDown) + } +} + /** `linkedCursorStream` as a Mount resolving `CompletedMountLinkedCursor`. */ export const MountLinkedCursor = Mount.defineStream("MountLinkedCursor", { messages: [LinkedCursorMessage.CompletedMountLinkedCursor], execute: ({ element }) => linkedCursorStream(element, LinkedCursorMessage.CompletedMountLinkedCursor()), }) -/** The linked cursor overlay, positioned and shown by `MountLinkedCursor`. */ +/** `MountLinkedCursor` for a group whose charts zoom: a drag across one resolves `SelectedChartRange`. */ +export const MountLinkedZoom = Mount.defineStream("MountLinkedZoom", { + args: { timeZone: Schema.String }, + messages: [LinkedCursorMessage.CompletedMountLinkedCursor, LinkedCursorMessage.SelectedChartRange], + execute: ({ element, timeZone }) => + linkedCursorStream(element, LinkedCursorMessage.CompletedMountLinkedCursor(), { + timeZone, + selected: (range) => LinkedCursorMessage.SelectedChartRange(range), + }), +}) + +/** The linked cursor overlay: the cursor line and the zoom band, positioned and shown by the Mount. */ export const linkedCursorOverlay = (h: HtmlBuilder, chartId: string): Html => h.div( [ h.AriaHidden(true), h.Class("pointer-events-none absolute"), h.DataAttribute("linked-cursor-overlay", chartId), - h.Style({ "container-type": "inline-size", opacity: "var(--linked-cursor-visible)" }), + h.Style({ "container-type": "inline-size" }), ], [ h.div([ + h.DataAttribute("linked-cursor-line", ""), h.Class("absolute inset-y-0 left-0 w-px bg-foreground-faint will-change-transform"), - h.Style({ transform: "translateX(calc(var(--linked-cursor-ratio) * (100cqw - 1px)))" }), + h.Style({ + opacity: "var(--linked-cursor-visible)", + transform: "translateX(calc(var(--linked-cursor-ratio) * (100cqw - 1px)))", + }), ]), + h.div( + [ + h.DataAttribute("linked-zoom-band", ""), + h.Class("absolute inset-y-0 border-x border-primary-a4 bg-primary-a2"), + h.Style({ + left: "calc(var(--linked-zoom-start, 0) * 100cqw)", + width: "calc((var(--linked-zoom-end, 0) - var(--linked-zoom-start, 0)) * 100cqw)", + opacity: "var(--linked-zoom-visible, 0)", + }), + ], + [ + badge( + h, + { + // `max-w-none`: the label may be wider than a narrow band. + className: "absolute top-1 left-1/2 max-w-none -translate-x-1/2 empty:hidden", + attributes: [h.DataAttribute("linked-zoom-label", "")], + }, + [], + ), + ], + ), ], ) diff --git a/packages/views/src/charts/plot-time-axis.test.ts b/packages/views/src/charts/plot-time-axis.test.ts new file mode 100644 index 0000000000..c3ca041b9d --- /dev/null +++ b/packages/views/src/charts/plot-time-axis.test.ts @@ -0,0 +1,31 @@ +import { Option } from "effect" +import { describe, expect, test } from "vitest" +import { decodePlotTimeAxis, encodePlotTimeAxis, timeAtPlotX } from "./plot-time-axis" + +const axis = { domainStartMs: 1_000_000, domainEndMs: 1_600_000, startPx: 10, endPx: 310 } + +describe("plot time axis", () => { + test("what the chart writes on its anchor reads back as the same scale", () => { + expect(decodePlotTimeAxis(encodePlotTimeAxis(axis))).toEqual(Option.some(axis)) + }) + + test("a missing, garbled or spanless attribute is no axis", () => { + expect([ + decodePlotTimeAxis(null), + decodePlotTimeAxis(""), + decodePlotTimeAxis("1,2,x,4"), + decodePlotTimeAxis("5,5,0,100"), + decodePlotTimeAxis("1,2,40,40"), + ]).toEqual([Option.none(), Option.none(), Option.none(), Option.none(), Option.none()]) + }) + + test("a pixel maps to its time, and a pixel off the scale stops at the domain's edge", () => { + expect([ + timeAtPlotX(axis, 10), + timeAtPlotX(axis, 160), + timeAtPlotX(axis, 310), + timeAtPlotX(axis, -50), + timeAtPlotX(axis, 900), + ]).toEqual([1_000_000, 1_300_000, 1_600_000, 1_000_000, 1_600_000]) + }) +}) diff --git a/packages/views/src/charts/plot-time-axis.ts b/packages/views/src/charts/plot-time-axis.ts new file mode 100644 index 0000000000..a1cda0f2cf --- /dev/null +++ b/packages/views/src/charts/plot-time-axis.ts @@ -0,0 +1,39 @@ +import { Option, String as Str, Array as Arr } from "effect" + +// A time chart's x scale, written on its plot anchor so DOM-side code (the linked zoom) can turn a +// pixel into a time without a round trip through the Model. + +export const PLOT_TIME_AXIS_ATTRIBUTE = "data-chart-time-axis" + +/** The scale's domain in epoch ms and where its two ends sit, in px from the plot's left edge. */ +export interface PlotTimeAxis { + readonly domainStartMs: number + readonly domainEndMs: number + readonly startPx: number + readonly endPx: number +} + +export const encodePlotTimeAxis = (axis: PlotTimeAxis): string => + Arr.join(Arr.map([axis.domainStartMs, axis.domainEndMs, axis.startPx, axis.endPx], String), ",") + +/** `None` for a missing or unreadable attribute, and for a scale with no span to map. */ +export const decodePlotTimeAxis = (value: string | null): Option.Option => { + if (value === null) return Option.none() + const [domainStartMs, domainEndMs, startPx, endPx] = Arr.map(Str.split(",")(value), Number) + return domainStartMs === undefined || + domainEndMs === undefined || + startPx === undefined || + endPx === undefined || + !Arr.every([domainStartMs, domainEndMs, startPx, endPx], Number.isFinite) || + domainEndMs <= domainStartMs || + endPx === startPx + ? Option.none() + : Option.some({ domainStartMs, domainEndMs, startPx, endPx }) +} + +/** The time under `x` (px from the plot's left edge), held inside the domain. */ +export const timeAtPlotX = (axis: PlotTimeAxis, x: number): number => { + const span = axis.domainEndMs - axis.domainStartMs + const ms = axis.domainStartMs + ((x - axis.startPx) / (axis.endPx - axis.startPx)) * span + return Math.min(axis.domainEndMs, Math.max(axis.domainStartMs, ms)) +} diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 5d7c6d634e..aff12112f2 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -203,6 +203,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/billing/usage.ts` `formatUsage`, `formatUsageAgainst`, `formatCount`, `quoteUnits`, `formatQuoteUnits`, `formatCredits`: "0.00 GB of 100.00 GB": both sides in one unit and precision, chosen by the larger amount, so a quiet feature never reads in a different unit than its neighbours. - `apps/web/src/page/kit/chain-connector.ts` `chainConnector`: The line between two cards of a vertical chain, with a + on it that adds a card there. - `apps/web/src/page/kit/chart-card.ts` `chartCard`, `chartCardMessage`: The card a chart sits in: an optional header (title, legend, scope) over the plot area. +- `apps/web/src/page/kit/chart-zoom.ts` `zoomSearchPatch`: A span dragged across a chart as the URL's absolute window: whole seconds, at least a minute, no preset. - `apps/web/src/page/kit/checkout-return.ts` `confirmed` (stateful: Model, init, update): Back from Stripe (`?checkout=complete`), a page waits on the Autumn sync. - `apps/web/src/page/kit/clipboard.ts` `copyToast`: The toast for a finished copy (no label: the generic titles). - `apps/web/src/page/kit/code-block.ts` `codeBlock`: A code block: language eyebrow, copy button, highlighted markup.