From 5ecabf8f72212b4d604f67dfbe7d49a5b914835f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:14:09 +0100 Subject: [PATCH] feat(charts): drag across a linked chart group to zoom its page to that span Drag to zoom existed only on the logs histogram, in that page's own code. It is now part of the linked cursor: a drag across any time chart in a group paints a band on every sibling, labels the span, and on release the page opens that span as its window. The navigation is a push, so Back returns to the window the drag started in. The chart writes its time scale on its plot anchor, so the drag turns pixels into times in the DOM and no Message is sent until release. A press that moves under 5px stays a click, Escape cancels, touch still scrolls, and the chart's own tooltip steps aside while the band is up. Service detail, release detail, Overview and host detail opt in with MountLinkedZoom and one handler. Groups on MountLinkedCursor are unchanged. --- apps/web/src/lab/bench/chart-bench.ts | 8 +- .../web/src/lab/bench/service-detail-bench.ts | 17 +- .../hosts/detail/host-detail.scene.test.ts | 4 +- .../src/page/infra/hosts/detail/message.ts | 1 + .../web/src/page/infra/hosts/detail/update.ts | 2 + .../page/infra/hosts/detail/view-panels.ts | 9 +- apps/web/src/page/kit/chart-zoom.test.ts | 26 ++ apps/web/src/page/kit/chart-zoom.ts | 16 + apps/web/src/page/kit/service-metrics/grid.ts | 16 +- .../overview/analyst/analyst.scene.test.ts | 4 +- apps/web/src/page/overview/message.ts | 1 + .../src/page/overview/overview.scene.test.ts | 26 +- .../overview/setup-checklist.scene.test.ts | 4 +- apps/web/src/page/overview/update.ts | 6 +- .../page/overview/view-metrics.scene.test.ts | 4 +- apps/web/src/page/overview/view-metrics.ts | 22 +- apps/web/src/page/releases/detail/message.ts | 1 + .../releases/detail/release.scene.test.ts | 4 +- apps/web/src/page/releases/detail/update.ts | 2 + .../src/page/releases/detail/view-service.ts | 4 +- .../page/services/detail/detail.scene.test.ts | 24 +- apps/web/src/page/services/detail/message.ts | 1 + apps/web/src/page/services/detail/update.ts | 3 + .../services/detail/uptime-card.scene.test.ts | 4 +- .../src/page/services/detail/view-charts.ts | 11 +- .../src/page/services/detail/view-overview.ts | 21 +- apps/web/src/update.audience.test.ts | 4 +- packages/views/src/charts/chart-mounts.ts | 19 + .../views/src/charts/linked-cursor.test.ts | 25 ++ packages/views/src/charts/linked-cursor.ts | 327 ++++++++++++++++-- .../views/src/charts/plot-time-axis.test.ts | 31 ++ packages/views/src/charts/plot-time-axis.ts | 39 +++ packages/views/src/ui/INDEX.md | 1 + 33 files changed, 607 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/page/kit/chart-zoom.test.ts create mode 100644 apps/web/src/page/kit/chart-zoom.ts create mode 100644 packages/views/src/charts/linked-cursor.test.ts create mode 100644 packages/views/src/charts/plot-time-axis.test.ts create mode 100644 packages/views/src/charts/plot-time-axis.ts 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.