TanStack
Catalog

Editable event range

interaction

TanStack

benchmarks/conformance/cases/92-editable-event-range/tanstack.ts
import { defineChart, mountChart, rect, text } from '@tanstack/charts'
import { focusDisabled } from '@tanstack/charts/focus/disabled'
import { scaleBand, scaleOrdinal, scaleUtc } from 'd3-scale'
import {
  clampEditableEventEnd,
  editableDateFromAnchor,
  editableDateKey,
  editableDomain,
  editableDurationDays,
  editableEventColor,
  editableEvents,
  editableEventStart,
  editableLanes,
  initialEditableEventEnd,
} from './data'
import { createEditableHandleOverlay } from './overlay'
import type {
  ChartHost,
  ChartScene,
  DynamicChartHostOptions,
} from '@tanstack/charts'
import type { EditableEvent, EditableEventId } from './data'
import type { EditableHandleLayout } from './overlay'
import type {
  ConformanceGeometryQuery,
  ConformanceGeometrySample,
  ConformanceInput,
  ConformanceMount,
  ConformanceTarget,
  ConformanceTestDriver,
} from '../../types'

interface EditableChartInput extends ConformanceInput {
  end: Date
}

interface EditableState {
  end: Date
  editing: boolean
  editCount: number
  originEnd: Date | null
}

const margin = { top: 96, right: 26, bottom: 48, left: 82 }
const millisecondsPerDay = 86_400_000

const definition = defineChart<EditableChartInput>()(({ input }) => {
  const rows = editableEvents(input.revision, input.end)
  const outsideLabels = rows
    .filter((row) => row.id !== 'release')
    .map((row) => ({
      ...row,
      labelDate: row.end,
    }))
  const releaseLabels = rows
    .filter(
      (row) =>
        row.id === 'release' &&
        eventBarCanFitLabel(row, input.width, 'Release'),
    )
    .map((row) => ({
      ...row,
      labelDate: row.start,
      shortLabel: 'Release',
    }))
  return {
    marks: [
      rect(rows, {
        id: 'editable-events',
        x1: 'start',
        x2: 'end',
        y: 'lane',
        z: 'id',
        key: 'id',
        radius: 5,
        stroke: '#ffffff',
        strokeWidth: 1,
      }),
      text(outsideLabels, {
        id: 'editable-event-labels',
        x: 'labelDate',
        y: 'lane',
        text: 'label',
        key: 'id',
        anchor: 'start',
        dx: 5,
        fill: 'currentColor',
        fontSize: 10,
        fontWeight: 600,
      }),
      text(releaseLabels, {
        id: 'editable-release-label',
        x: 'labelDate',
        y: 'lane',
        text: 'shortLabel',
        key: 'id',
        anchor: 'start',
        dx: 5,
        fill: '#431407',
        fontSize: 10,
        fontWeight: 700,
      }),
    ],
    x: {
      scale: scaleUtc().domain(editableDomain),
      grid: true,
      format: (value) =>
        value.toLocaleDateString(undefined, {
          month: 'short',
          day: 'numeric',
          timeZone: 'UTC',
        }),
    },
    y: {
      scale: scaleBand<string>()
        .domain(editableLanes)
        .paddingInner(0.38)
        .paddingOuter(0.19),
      grid: false,
    },
    color: {
      scale: scaleOrdinal<EditableEventId, string>()
        .domain(['discovery', 'design', 'campaign', 'release'])
        .range([
          editableEventColor('discovery'),
          editableEventColor('design'),
          editableEventColor('campaign'),
          editableEventColor('release'),
        ]),
    },
    margin,
  }
})

export const mount: ConformanceMount = (container, input) => {
  let currentInput = input
  const state: EditableState = {
    end: initialEditableEventEnd,
    editing: false,
    editCount: 0,
    originEnd: null,
  }
  const view = container.ownerDocument.createElement('div')
  view.dataset.conformanceView = 'main'
  view.style.position = 'relative'
  view.style.touchAction = 'pan-y'
  sizeView(view, input)

  const chartSurface = container.ownerDocument.createElement('div')
  view.append(chartSurface)
  container.append(view)

  const options = (): DynamicChartHostOptions<
    EditableEvent,
    EditableChartInput
  > => ({
    definition,
    input: {
      ...currentInput,
      end: state.end,
    },
    width: currentInput.width,
    height: currentInput.height,
    ariaLabel: editableAriaLabel(currentInput.revision, state.end),
    animate: false,
    keyboard: false,
    focus: focusDisabled,
  })
  const host = mountChart(chartSurface, options())
  const interactions = createEditableInteractions(
    chartSurface,
    view,
    () => currentInput,
    state,
    host,
    options,
  )
  interactions.paint()

  return {
    driver: interactions.driver,
    update(nextInput) {
      currentInput = nextInput
      sizeView(view, nextInput)
      host.update(options())
      interactions.paint()
    },
    destroy() {
      interactions.destroy()
      host.destroy()
      view.remove()
    },
  }
}

function createEditableInteractions(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  getInput: () => ConformanceInput,
  state: EditableState,
  host: ChartHost<EditableEvent, EditableChartInput>,
  options: () => DynamicChartHostOptions<EditableEvent, EditableChartInput>,
) {
  let paint = () => {}
  const beginEdit = () => {
    if (state.editing) return
    state.originEnd = state.end
    state.editing = true
  }
  const setEnd = (date: Date) => {
    state.end = clampEditableEventEnd(date)
    host.update(options())
    paint()
  }
  const overlay = createEditableHandleOverlay(
    view,
    (value) => {
      beginEdit()
      setEnd(new Date(editableDomain[0].getTime() + value * millisecondsPerDay))
    },
    (value) => {
      const date = editableDateFromAnchor(`date:${value}`)
      if (!date) return false
      beginEdit()
      setEnd(date)
      return true
    },
  )
  const layout = () =>
    editableLayout(chartSurface, view, host.getScene(), state.end)
  paint = () => {
    const nextLayout = layout()
    if (!nextLayout) return
    overlay.paint(nextLayout, {
      value: editableDayIndex(state.end),
      min: editableDayIndex(
        new Date(editableEventStart.getTime() + millisecondsPerDay),
      ),
      max: editableDayIndex(editableDomain[1]),
      date: editableDateKey(state.end),
      minDate: editableDateKey(
        new Date(editableEventStart.getTime() + millisecondsPerDay),
      ),
      maxDate: editableDateKey(editableDomain[1]),
      valueText: `Release: ${editableDateKey(editableEventStart)}${editableDateKey(state.end)} · ${editableDurationDays(editableEventStart, state.end)} days`,
      summaryText: `Release · ${compactDate(editableEventStart)}${compactDate(state.end)} · ${editableDurationDays(editableEventStart, state.end)} days`,
      eventDescriptions: editableEvents(getInput().revision, state.end).map(
        (row) =>
          `${row.label}: ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
      ),
    })
  }

  const commitEdit = () => {
    if (!state.editing) return
    state.editing = false
    state.originEnd = null
    state.editCount += 1
    paint()
  }

  const cancelEdit = () => {
    if (!state.editing) return
    const originEnd = state.originEnd
    state.editing = false
    state.originEnd = null
    if (originEnd) setEnd(originEnd)
    else paint()
  }

  overlay.range.addEventListener('pointerdown', beginEdit)
  overlay.range.addEventListener('change', commitEdit)
  overlay.range.addEventListener('pointercancel', cancelEdit)
  overlay.dateInput.addEventListener('change', commitEdit)
  overlay.dateInput.addEventListener('pointercancel', cancelEdit)

  const driver: ConformanceTestDriver = {
    resolveTarget(target) {
      return resolveTarget(
        chartSurface,
        view,
        host.getScene(),
        state.end,
        target,
      )
    },
    readState() {
      return interactionState(state, getInput())
    },
    geometry(query) {
      return editableGeometry(
        chartSurface,
        view,
        host.getScene(),
        getInput(),
        state.end,
        layout(),
        overlay.handleGeometry(),
        overlay.trackGeometry(),
        query,
      )
    },
    viewBounds(viewName) {
      return viewName === undefined || viewName === 'main'
        ? clientBounds(view)
        : null
    },
    settle: paint,
  }

  return {
    driver,
    paint,
    destroy() {
      overlay.range.removeEventListener('pointerdown', beginEdit)
      overlay.range.removeEventListener('change', commitEdit)
      overlay.range.removeEventListener('pointercancel', cancelEdit)
      overlay.dateInput.removeEventListener('change', commitEdit)
      overlay.dateInput.removeEventListener('pointercancel', cancelEdit)
      overlay.destroy()
    },
  }
}

function resolveTarget(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  end: Date,
  target: ConformanceTarget,
) {
  if (target.view !== undefined && target.view !== 'main') return null
  if (target.anchor === 'control:date') {
    const dateInput = view.querySelector<HTMLInputElement>(
      '.ts-conformance-event-date',
    )
    if (!dateInput) return null
    const bounds = dateInput.getBoundingClientRect()
    return {
      x: bounds.left + bounds.width / 2,
      y: bounds.top + bounds.height / 2,
      focusElement: dateInput,
    }
  }
  const date =
    target.anchor === 'event:release:end'
      ? end
      : editableDateFromAnchor(target.anchor)
  if (!date) return null
  const point = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(date),
    scene.scales.y.map('Engineering'),
  )
  if (!point) return null
  const bounds = view.getBoundingClientRect()
  return {
    x: bounds.left + point[0],
    y: bounds.top + point[1],
    focusElement:
      view.querySelector<HTMLInputElement>('.ts-conformance-event-range') ??
      undefined,
  }
}

function interactionState(state: EditableState, input: ConformanceInput) {
  const rows = editableEvents(input.revision, state.end)
  const design = rows.find((row) => row.id === 'design')
  const campaign = rows.find((row) => row.id === 'campaign')
  return {
    editor: {
      id: 'release',
      start: editableDateKey(editableEventStart),
      end: editableDateKey(state.end),
      durationDays: editableDurationDays(editableEventStart, state.end),
      editing: state.editing,
      editCount: state.editCount,
    },
    events: {
      count: rows.length,
      ids: rows.map((row) => row.id),
      designEnd: design ? editableDateKey(design.end) : null,
      campaignStart: campaign ? editableDateKey(campaign.start) : null,
    },
  }
}

function editableGeometry(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  input: ConformanceInput,
  end: Date,
  layout: EditableHandleLayout | null,
  overlayHandle: ConformanceGeometrySample,
  overlayTrack: ConformanceGeometrySample,
  query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
  if (!layout || (query.view !== undefined && query.view !== 'main')) {
    return []
  }
  if (query.role === 'dot') return [overlayHandle]
  if (query.role === 'rule') return [overlayTrack]
  if (query.role !== 'rect') return []
  const viewBounds = view.getBoundingClientRect()
  const height = scene.scales.y.bandwidth
  return editableEvents(input.revision, end).flatMap((row) => {
    const start = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane),
    )
    const finish = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.end),
      scene.scales.y.map(row.lane),
    )
    const top = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane) - height / 2,
    )
    const bottom = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane) + height / 2,
    )
    if (!start || !finish || !top || !bottom) return []
    return [
      {
        x: viewBounds.left + Math.min(start[0], finish[0]),
        y: viewBounds.top + Math.min(top[1], bottom[1]),
        width: Math.abs(finish[0] - start[0]),
        height: Math.abs(bottom[1] - top[1]),
        paint: editableEventColor(row.id),
      },
    ]
  })
}

function editableLayout(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  end: Date,
): EditableHandleLayout | null {
  const point = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(end),
    scene.scales.y.map('Engineering'),
  )
  const minimum = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(
      new Date(editableEventStart.getTime() + millisecondsPerDay),
    ),
    scene.scales.y.map('Engineering'),
  )
  const maximum = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(editableDomain[1]),
    scene.scales.y.map('Engineering'),
  )
  return point && minimum && maximum
    ? { x: point[0], y: point[1], minX: minimum[0], maxX: maximum[0] }
    : null
}

function sceneLocalPoint(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  sceneX: number,
  sceneY: number,
): readonly [number, number] | null {
  const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
  if (!svg) return null
  const svgBounds = svg.getBoundingClientRect()
  const viewBounds = view.getBoundingClientRect()
  return [
    svgBounds.left - viewBounds.left + (sceneX / scene.width) * svgBounds.width,
    svgBounds.top - viewBounds.top + (sceneY / scene.height) * svgBounds.height,
  ]
}

function editableDayIndex(date: Date) {
  return Math.round(
    (date.getTime() - editableDomain[0].getTime()) / millisecondsPerDay,
  )
}

function clientBounds(element: HTMLElement): ConformanceGeometrySample {
  const bounds = element.getBoundingClientRect()
  return {
    x: bounds.left,
    y: bounds.top,
    width: bounds.width,
    height: bounds.height,
  }
}

function sizeView(view: HTMLDivElement, input: ConformanceInput) {
  view.style.width = `${input.width}px`
  view.style.height = `${input.height}px`
}

function compactDate(date: Date) {
  return date.toLocaleDateString(undefined, {
    month: 'short',
    day: 'numeric',
    timeZone: 'UTC',
  })
}

function editableAriaLabel(revision: number, end: Date) {
  return `Editable schedule. ${editableEvents(revision, end)
    .map(
      (row) =>
        `${row.label}, ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
    )
    .join('. ')}.`
}

function eventBarCanFitLabel(
  event: EditableEvent,
  width: number,
  label: string,
) {
  const plotWidth = Math.max(0, width - margin.left - margin.right)
  const domainWidth = editableDomain[1].getTime() - editableDomain[0].getTime()
  const eventWidth = event.end.getTime() - event.start.getTime()
  const barWidth = domainWidth > 0 ? (eventWidth / domainWidth) * plotWidth : 0
  return barWidth >= label.length * 6 + 10
}