Skip to content

Commit

Permalink
Basic date-time axis (#1398)
Browse files Browse the repository at this point in the history
* [#181971361] Feature: Date attributes can be plotted on a date-time axis

This is a "first pass" implementation of a date-time axis. Missing features and bugs should be logged in separate PT stories unless they break non-date-time axes or otherwise disrupt normal V3 behavior.

* Detect "date" as a valid attribute type when setting primary type in setPrimaryRoleAndPlotType
* Define `AbstractNumericAxisModel` with descendant `NumericAxisModel`
* Add DateAxisModel as a descendant of `AbstractNumericAxisModel`
* Allow for creation of DatAxisModel in `setupAxes`
* Defined AxisHelper and subclasses for empty, numeric and categorical. Create these in useSubAxis.
* DateAxisHelper is responsible for rendering the data axis scale
* Both NumericAxisModel and DateAxisModel return true for the new test `isAbstractNumeric`
* Added data-display-value-utils.ts with dataDisplayGetNumericValue as its only member (so far) to lessen likelihood of Lakosian violations
* To get drag rescale working, define `isAbstractNumericAxisModel` and use it, where appropriate, in place of `isNumericAxisModel`

* * Code review changes

* * Code review changes
* Added axis helper jest tests
  • Loading branch information
bfinzer authored Aug 16, 2024
1 parent 7c68efa commit 50fdc6d
Show file tree
Hide file tree
Showing 40 changed files with 1,330 additions and 361 deletions.
6 changes: 6 additions & 0 deletions v3/src/components/axis/axis-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {ScaleLinear} from "d3"
import { determineLevels } from "../../utilities/date-utils"
import {kAxisGap, kAxisTickLength} from "../graph/graphing-types"
import {kDataDisplayFont} from "../data-display/data-display-types"
import {AxisPlace} from "./axis-types"
Expand Down Expand Up @@ -233,3 +234,8 @@ export const computeBestNumberOfTicks = (scale: ScaleLinear<number, number>): nu
export const isScaleLinear = (scale: any): scale is ScaleLinear<number, number> => {
return (scale as ScaleLinear<number, number>).interpolate !== undefined
}

export const getNumberOfLevelsForDateAxis = (minDateInSecs: number, maxDateInSecs: number) => {
const levels = determineLevels(1000 * minDateInSecs, 1000 * maxDateInSecs)
return levels.outerLevel !== levels.innerLevel ? 2 : 1
}
4 changes: 2 additions & 2 deletions v3/src/components/axis/components/numeric-axis-drag-rects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import {drag, ScaleContinuousNumeric, select} from "d3"
import { t } from "../../../utilities/translation/translate"
import {RectIndices, selectDragRects} from "../axis-types"
import {useAxisLayoutContext} from "../models/axis-layout-context"
import {INumericAxisModel} from "../models/axis-model"
import {IBaseNumericAxisModel} from "../models/axis-model"
import {isVertical} from "../../axis-graph-shared"
import {MultiScale} from "../models/multi-scale"

import "./axis.scss"

interface IProps {
axisModel: INumericAxisModel
axisModel: IBaseNumericAxisModel
axisWrapperElt: SVGGElement | null
numSubAxes?: number
subAxisIndex?: number
Expand Down
4 changes: 2 additions & 2 deletions v3/src/components/axis/components/sub-axis.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { observer } from "mobx-react-lite"
import React, {useRef, useState} from "react"
import {AxisPlace} from "../axis-types"
import { isBaseNumericAxisModel } from "../models/axis-model"
import {useAxisProviderContext} from "../hooks/use-axis-provider-context"
import {useSubAxis} from "../hooks/use-sub-axis"
import {isNumericAxisModel} from "../models/axis-model"
import {NumericAxisDragRects} from "./numeric-axis-drag-rects"
import { useDataDisplayModelContext } from "../../data-display/hooks/use-data-display-model"

Expand Down Expand Up @@ -35,7 +35,7 @@ export const SubAxis = observer(function SubAxis({
return (
<g className='sub-axis-wrapper' ref={subWrapperElt}>
<g className='axis' ref={elt => setSubAxisElt(elt)}/>
{isNumericAxisModel(axisModel) && displayModel.hasDraggableNumericAxis(axisModel) &&
{isBaseNumericAxisModel(axisModel) && displayModel.hasDraggableNumericAxis(axisModel) &&
<NumericAxisDragRects
axisModel={axisModel}
axisWrapperElt={subWrapperElt.current}
Expand Down
89 changes: 89 additions & 0 deletions v3/src/components/axis/helper-models/axis-helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Tests for the AxisHelper class
import { select } from "d3"
import { AxisHelper, IAxisHelperArgs } from "./axis-helper"
import { IAxisModel } from "../models/axis-model"
import { IDataDisplayContentModel } from "../../data-display/models/data-display-content-model"
import { IAxisLayout } from "../models/axis-layout-context"

jest.mock("d3", () => ({
select: jest.fn().mockReturnValue({
selectAll: jest.fn().mockReturnValue({
remove: jest.fn()
}),
attr: jest.fn().mockReturnThis(),
append: jest.fn().mockReturnThis(),
style: jest.fn().mockReturnThis()
})
}))

describe("AxisHelper", () => {
let props: IAxisHelperArgs
let axisHelper: AxisHelper

beforeEach(() => {
props = {
displayModel: {} as IDataDisplayContentModel,
subAxisIndex: 0,
subAxisElt: document.createElementNS("http://www.w3.org/2000/svg", "g"),
axisModel: { place: "left" } as IAxisModel,
layout: {
getAxisMultiScale: jest.fn().mockReturnValue({ cellLength: 100 }),
getComputedBounds: jest.fn().mockReturnValue({ left: 0, top: 0, width: 100, height: 100 })
} as unknown as IAxisLayout,
isAnimating: jest.fn().mockReturnValue(false)
}
axisHelper = new AxisHelper(props)
})

it("should initialize correctly", () => {
expect(axisHelper.displayModel).toBe(props.displayModel)
expect(axisHelper.subAxisIndex).toBe(props.subAxisIndex)
expect(axisHelper.subAxisElt).toBe(props.subAxisElt)
expect(axisHelper.axisModel).toBe(props.axisModel)
expect(axisHelper.layout).toBe(props.layout)
expect(axisHelper.isAnimating).toBe(props.isAnimating)
expect(axisHelper.multiScale).toEqual({ cellLength: 100 })
})

it("should return correct axisPlace", () => {
expect(axisHelper.axisPlace).toBe("left")
})

it("should return correct dataConfig", () => {
expect(axisHelper.dataConfig).toBe(props.displayModel.dataConfiguration)
})

it("should return correct initialTransform", () => {
expect(axisHelper.initialTransform).toBe("translate(100, 0)")
})

it("should return correct isVertical", () => {
expect(axisHelper.isVertical).toBe(true)
})

it("should return correct subAxisLength", () => {
expect(axisHelper.subAxisLength).toBe(100)
})

it("should return correct axis", () => {
expect(axisHelper.axisModel).toBeDefined()
})

it("should return correct rangeMin", () => {
expect(axisHelper.rangeMin).toBe(0)
})

it("should return correct rangeMax", () => {
expect(axisHelper.rangeMax).toBe(100)
})

it("should render axis line correctly", () => {
axisHelper.renderAxisLine()
expect(select).toHaveBeenCalledWith(props.subAxisElt)
expect(select(props.subAxisElt).selectAll).toHaveBeenCalledWith('*')
expect(select(props.subAxisElt).attr).toHaveBeenCalledWith("transform", "translate(100, 0)")
expect(select(props.subAxisElt).append).toHaveBeenCalledWith('line')
expect(select(props.subAxisElt).style).toHaveBeenCalledWith("stroke", "darkgray")
expect(select(props.subAxisElt).style).toHaveBeenCalledWith("stroke-opacity", "0.7")
})
})
94 changes: 94 additions & 0 deletions v3/src/components/axis/helper-models/axis-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { select } from "d3"
import { isVertical } from "../../axis-graph-shared"
import { AxisBounds, axisPlaceToAxisFn } from "../axis-types"
import { IAxisModel } from "../models/axis-model"
import { IDataDisplayContentModel } from "../../data-display/models/data-display-content-model"
import { IAxisLayout } from "../models/axis-layout-context"
import { MultiScale } from "../models/multi-scale"

export interface IAxisHelperArgs {
displayModel: IDataDisplayContentModel
subAxisIndex: number
subAxisElt: SVGGElement | null
axisModel: IAxisModel
layout: IAxisLayout
isAnimating: () => boolean
}

export class AxisHelper {
displayModel: IDataDisplayContentModel
subAxisIndex: number
subAxisElt: SVGGElement | null
axisModel: IAxisModel
layout: IAxisLayout
isAnimating: () => boolean
multiScale: MultiScale | undefined

constructor(props: IAxisHelperArgs) {
this.displayModel = props.displayModel
this.subAxisIndex = props.subAxisIndex
this.subAxisElt = props.subAxisElt
this.axisModel = props.axisModel
this.layout = props.layout
this.isAnimating = props.isAnimating
this.multiScale = this.layout.getAxisMultiScale(this.axisPlace)
}

get axisPlace() {
return this.axisModel.place
}

get dataConfig() {
return this.displayModel.dataConfiguration
}

get initialTransform() {
const axisBounds = this.layout.getComputedBounds(this.axisPlace) as AxisBounds
return (this.axisPlace === 'left')
? `translate(${axisBounds.left + axisBounds.width}, ${axisBounds.top})`
: (this.axisPlace === 'top')
? `translate(${axisBounds.left}, ${axisBounds.top + axisBounds.height})`
: `translate(${axisBounds.left}, ${axisBounds.top})`
}

get isVertical() {
return isVertical(this.axisPlace)
}

get subAxisLength() {
return this.multiScale?.cellLength ?? 0
}

get axis() {
return axisPlaceToAxisFn(this.axisPlace)
}

get rangeMin() {
return this.subAxisIndex * this.subAxisLength
}

get rangeMax() {
return this.rangeMin + this.subAxisLength
}

renderAxisLine() {
select(this.subAxisElt).selectAll('*').remove()
select(this.subAxisElt)
.attr("transform", this.initialTransform)
.append('line')
.attr('x1', 0)
.attr('x2', this.isVertical ? 0 : this.subAxisLength)
.attr('y1', 0)
.attr('y2', this.isVertical ? this.subAxisLength : 0)
.style("stroke", "darkgray")
.style("stroke-opacity", "0.7")
}

}

export class EmptyAxisHelper extends AxisHelper {

render() {
this.renderAxisLine()
}
}
122 changes: 122 additions & 0 deletions v3/src/components/axis/helper-models/categorical-axis-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { BaseType, Selection } from "d3"
import { AxisHelper, IAxisHelperArgs } from "./axis-helper"
import { MutableRefObject } from "react"
import { kAxisTickLength } from "../../graph/graphing-types"
import { otherPlace } from "../axis-types"
import { axisPlaceToAttrRole, transitionDuration } from "../../data-display/data-display-types"
import {
collisionExists, DragInfo,
getCategoricalLabelPlacement,
getCoordFunctions,
IGetCoordFunctionsProps
} from "../axis-utils"

export interface CatObject {
cat: string
index: number
}

export interface ICategoricalAxisHelperArgs extends IAxisHelperArgs {
subAxisSelectionRef: MutableRefObject<Selection<SVGGElement, any, any, any> | undefined>
categoriesSelectionRef: MutableRefObject<Selection<SVGGElement | BaseType, CatObject, SVGGElement, any> | undefined>
swapInProgress: MutableRefObject<boolean>
centerCategoryLabels: boolean
dragInfo: MutableRefObject<DragInfo>
}

export class CategoricalAxisHelper extends AxisHelper {
subAxisSelectionRef: MutableRefObject<Selection<SVGGElement, any, any, any> | undefined>
categoriesSelectionRef: MutableRefObject<Selection<SVGGElement | BaseType, CatObject, SVGGElement, any> | undefined>
swapInProgress: MutableRefObject<boolean>
centerCategoryLabels: boolean
dragInfo: MutableRefObject<DragInfo>

constructor(props: ICategoricalAxisHelperArgs) {
super(props)
this.subAxisSelectionRef = props.subAxisSelectionRef
this.categoriesSelectionRef = props.categoriesSelectionRef
this.swapInProgress = props.swapInProgress
this.centerCategoryLabels = props.centerCategoryLabels
this.dragInfo = props.dragInfo
}

render() {
if (!(this.subAxisSelectionRef.current && this.categoriesSelectionRef.current)) return

const {isVertical, centerCategoryLabels, dragInfo} = this,
categorySet = this.multiScale?.categorySet,
dividerLength = this.layout.getAxisLength(otherPlace(this.axisPlace)) ?? 0,
isRightCat = this.axisPlace === 'rightCat',
isTop = this.axisPlace === 'top',
role = axisPlaceToAttrRole[this.axisPlace],
categories: string[] = this.dataConfig?.categoryArrayForAttrRole(role) ?? [],
numCategories = categories.length,
hasCategories = !(categories.length === 1 && categories[0] === "__main__"),
bandWidth = this.subAxisLength / numCategories,
collision = collisionExists({bandWidth, categories, centerCategoryLabels}),
{rotation, textAnchor} = getCategoricalLabelPlacement(this.axisPlace, this.centerCategoryLabels,
collision),
duration = (this.isAnimating() && !this.swapInProgress.current &&
dragInfo.current.indexOfCategory === -1) ? transitionDuration : 0

// Fill out dragInfo for use in drag callbacks
const dI = dragInfo.current
dI.categorySet = categorySet
dI.categories = categories
dI.bandwidth = bandWidth
dI.axisOrientation = isVertical ? 'vertical' : 'horizontal'
dI.labelOrientation = isVertical ? (collision ? 'horizontal' : 'vertical')
: (collision ? 'vertical' : 'horizontal')

const sAS = this.subAxisSelectionRef.current,
{ rangeMin, rangeMax, subAxisLength } = this

sAS.attr("transform", this.initialTransform)
.select('line')
.attr('x1', isVertical ? 0 : rangeMin)
.attr('x2', isVertical ? 0 : rangeMax)
.attr('y1', isVertical ? rangeMin : 0)
.attr('y2', isVertical ? rangeMax : 0)

const props: IGetCoordFunctionsProps = {
numCategories, centerCategoryLabels, collision, axisIsVertical: isVertical, rangeMin, rangeMax,
subAxisLength, isRightCat, isTop, dragInfo
},
fns = getCoordFunctions(props)

hasCategories && this.categoriesSelectionRef.current
.join(
enter => enter,
update => {
update.select('.tick')
.attr('x1', (d, i) => fns.getTickX(i))
.attr('x2', (d, i) => isVertical
? (isRightCat ? 1 : -1) * kAxisTickLength : fns.getTickX(i))
.attr('y1', (d, i) => fns.getTickY(i))
.attr('y2', (d, i) => isVertical
? fns.getTickY(i) : (isTop ? -1 : 1) * kAxisTickLength)
// divider between groups
update.select('.divider')
.attr('x1', (d, i) => fns.getDividerX(i))
.attr('x2', (d, i) => isVertical
? (isRightCat ? -1 : 1) * dividerLength : fns.getDividerX(i))
.attr('y1', (d, i) => fns.getDividerY(i))
.attr('y2', (d, i) => isVertical
? fns.getDividerY(i) : (isTop ? 1 : -1) * dividerLength)
// labels
update.select('.category-label')
.attr('transform', `${rotation}`)
.attr('text-anchor', textAnchor)
.attr('transform-origin', (d, i) => {
return `${fns.getLabelX(i)} ${fns.getLabelY(i)}`
})
.transition().duration(duration)
.attr('class', 'category-label')
.attr('x', (d, i) => fns.getLabelX(i))
.attr('y', (d, i) => fns.getLabelY(i))
.text((catObject: CatObject) => String(catObject.cat))
return update
}
)
}
}
Loading

0 comments on commit 50fdc6d

Please sign in to comment.