From 50fdc6d2f6a03b5a1bcf07798d1b86538512b021 Mon Sep 17 00:00:00 2001 From: William Finzer Date: Fri, 16 Aug 2024 12:22:36 -0700 Subject: [PATCH] Basic date-time axis (#1398) * [#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 --- v3/src/components/axis/axis-utils.ts | 6 + .../components/numeric-axis-drag-rects.tsx | 4 +- .../components/axis/components/sub-axis.tsx | 4 +- .../axis/helper-models/axis-helper.test.ts | 89 ++++ .../axis/helper-models/axis-helper.ts | 94 ++++ .../helper-models/categorical-axis-helper.ts | 122 +++++ .../helper-models/date-axis-helper.test.ts | 75 +++ .../axis/helper-models/date-axis-helper.ts | 437 ++++++++++++++++++ .../helper-models/numeric-axis-helper.test.ts | 85 ++++ .../axis/helper-models/numeric-axis-helper.ts | 71 +++ .../axis/hooks/use-axis-provider-context.ts | 4 +- .../components/axis/hooks/use-axis.test.tsx | 4 +- v3/src/components/axis/hooks/use-axis.ts | 181 ++++---- v3/src/components/axis/hooks/use-sub-axis.ts | 251 +++------- v3/src/components/axis/models/axis-model.ts | 45 +- v3/src/components/case-table/case-table.tsx | 2 +- .../data-display/data-display-utils.ts | 1 + .../data-display/data-display-value-utils.ts | 13 + .../hooks/use-data-display-model.ts | 4 +- .../models/data-configuration-model.ts | 22 +- .../models/data-display-content-model.ts | 4 +- .../adornments/adornment-component-info.ts | 6 +- .../graph/adornments/adornment-utils.ts | 4 +- .../adornments/lsrl/lsrl-adornment-model.ts | 5 +- .../movable-value-adornment-model.ts | 8 +- ...riate-measure-adornment-base-component.tsx | 6 +- .../univariate-measure-adornment-model.ts | 3 +- .../graph/component-handler-graph.test.ts | 8 +- .../graph/components/scatter-plot-utils.ts | 9 +- .../graph/components/scatterdots.tsx | 11 +- v3/src/components/graph/graphing-types.ts | 3 +- .../graph/hooks/use-dot-plot-drag-drop.ts | 5 +- v3/src/components/graph/hooks/use-dot-plot.ts | 5 +- .../components/graph/hooks/use-graph-model.ts | 10 +- .../graph/models/graph-content-model.ts | 22 +- .../graph/models/graph-model-utils.ts | 24 +- .../graph/utilities/dot-plot-utils.ts | 7 +- .../components/graph/utilities/graph-utils.ts | 7 +- v3/src/components/slider/slider-model.ts | 6 +- v3/src/utilities/date-utils.ts | 24 +- 40 files changed, 1330 insertions(+), 361 deletions(-) create mode 100644 v3/src/components/axis/helper-models/axis-helper.test.ts create mode 100644 v3/src/components/axis/helper-models/axis-helper.ts create mode 100644 v3/src/components/axis/helper-models/categorical-axis-helper.ts create mode 100644 v3/src/components/axis/helper-models/date-axis-helper.test.ts create mode 100644 v3/src/components/axis/helper-models/date-axis-helper.ts create mode 100644 v3/src/components/axis/helper-models/numeric-axis-helper.test.ts create mode 100644 v3/src/components/axis/helper-models/numeric-axis-helper.ts create mode 100644 v3/src/components/data-display/data-display-value-utils.ts diff --git a/v3/src/components/axis/axis-utils.ts b/v3/src/components/axis/axis-utils.ts index 880beafd65..f8e4890046 100644 --- a/v3/src/components/axis/axis-utils.ts +++ b/v3/src/components/axis/axis-utils.ts @@ -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" @@ -233,3 +234,8 @@ export const computeBestNumberOfTicks = (scale: ScaleLinear): nu export const isScaleLinear = (scale: any): scale is ScaleLinear => { return (scale as ScaleLinear).interpolate !== undefined } + +export const getNumberOfLevelsForDateAxis = (minDateInSecs: number, maxDateInSecs: number) => { + const levels = determineLevels(1000 * minDateInSecs, 1000 * maxDateInSecs) + return levels.outerLevel !== levels.innerLevel ? 2 : 1 +} diff --git a/v3/src/components/axis/components/numeric-axis-drag-rects.tsx b/v3/src/components/axis/components/numeric-axis-drag-rects.tsx index 050d4b0ec2..26fa2eee41 100644 --- a/v3/src/components/axis/components/numeric-axis-drag-rects.tsx +++ b/v3/src/components/axis/components/numeric-axis-drag-rects.tsx @@ -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 diff --git a/v3/src/components/axis/components/sub-axis.tsx b/v3/src/components/axis/components/sub-axis.tsx index a600726e89..2e9f9a9356 100644 --- a/v3/src/components/axis/components/sub-axis.tsx +++ b/v3/src/components/axis/components/sub-axis.tsx @@ -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" @@ -35,7 +35,7 @@ export const SubAxis = observer(function SubAxis({ return ( setSubAxisElt(elt)}/> - {isNumericAxisModel(axisModel) && displayModel.hasDraggableNumericAxis(axisModel) && + {isBaseNumericAxisModel(axisModel) && displayModel.hasDraggableNumericAxis(axisModel) && ({ + 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") + }) +}) diff --git a/v3/src/components/axis/helper-models/axis-helper.ts b/v3/src/components/axis/helper-models/axis-helper.ts new file mode 100644 index 0000000000..fdae26c3e6 --- /dev/null +++ b/v3/src/components/axis/helper-models/axis-helper.ts @@ -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() + } +} diff --git a/v3/src/components/axis/helper-models/categorical-axis-helper.ts b/v3/src/components/axis/helper-models/categorical-axis-helper.ts new file mode 100644 index 0000000000..35615a215e --- /dev/null +++ b/v3/src/components/axis/helper-models/categorical-axis-helper.ts @@ -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 | undefined> + categoriesSelectionRef: MutableRefObject | undefined> + swapInProgress: MutableRefObject + centerCategoryLabels: boolean + dragInfo: MutableRefObject +} + +export class CategoricalAxisHelper extends AxisHelper { + subAxisSelectionRef: MutableRefObject | undefined> + categoriesSelectionRef: MutableRefObject | undefined> + swapInProgress: MutableRefObject + centerCategoryLabels: boolean + dragInfo: MutableRefObject + + 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 + } + ) + } +} diff --git a/v3/src/components/axis/helper-models/date-axis-helper.test.ts b/v3/src/components/axis/helper-models/date-axis-helper.test.ts new file mode 100644 index 0000000000..38e500176a --- /dev/null +++ b/v3/src/components/axis/helper-models/date-axis-helper.test.ts @@ -0,0 +1,75 @@ +import { select, Selection } from "d3" +import { DateAxisHelper, IDateAxisHelperArgs } from "./date-axis-helper" +import { IDateAxisModel } from "../models/axis-model" +import { IDataDisplayContentModel } from "../../data-display/models/data-display-content-model" +import { IAxisLayout } from "../models/axis-layout-context" +import { MutableRefObject } from "react" + +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(), + call: jest.fn().mockReturnThis(), + transition: jest.fn().mockReturnThis(), + duration: jest.fn().mockReturnThis() + }) +})) + +describe("DateAxisHelper", () => { + let props: IDateAxisHelperArgs + let dateAxisHelper: DateAxisHelper + let subAxisSelectionRef: MutableRefObject | undefined> + + beforeEach(() => { + subAxisSelectionRef = + { current: select(document.createElementNS("http://www.w3.org/2000/svg", + "g")) } as MutableRefObject | undefined> + props = { + displayModel: {} as IDataDisplayContentModel, + subAxisIndex: 0, + subAxisElt: document.createElementNS("http://www.w3.org/2000/svg", "g"), + axisModel: { + place: "left", + domain: [0, 100], + min: 0, + max: 100, + type: undefined, + scale: undefined, + lockZero: undefined + } as unknown as IDateAxisModel, + layout: { + getAxisMultiScale: jest.fn().mockReturnValue({ cellLength: 100 }), + getComputedBounds: jest.fn().mockReturnValue({ left: 0, top: 0, width: 100, height: 100 }), + getAxisLength: jest.fn().mockReturnValue(100) + } as unknown as IAxisLayout, + isAnimating: jest.fn().mockReturnValue(false), + subAxisSelectionRef + } + dateAxisHelper = new DateAxisHelper(props) + }) + + it("should initialize correctly", () => { + expect(dateAxisHelper.displayModel).toBe(props.displayModel) + expect(dateAxisHelper.subAxisIndex).toBe(props.subAxisIndex) + expect(dateAxisHelper.subAxisElt).toBe(props.subAxisElt) + expect(dateAxisHelper.axisModel).toBe(props.axisModel) + expect(dateAxisHelper.layout).toBe(props.layout) + expect(dateAxisHelper.isAnimating).toBe(props.isAnimating) + expect(dateAxisHelper.subAxisSelectionRef).toBe(props.subAxisSelectionRef) + }) + + it.skip("should render correctly", () => { + dateAxisHelper.render() + 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).transition).toHaveBeenCalled() + expect(select(props.subAxisElt).call).toHaveBeenCalled() + expect(select(props.subAxisElt).style).toHaveBeenCalledWith("stroke", "lightgrey") + expect(select(props.subAxisElt).style).toHaveBeenCalledWith("stroke-opacity", "0.7") + }) +}) diff --git a/v3/src/components/axis/helper-models/date-axis-helper.ts b/v3/src/components/axis/helper-models/date-axis-helper.ts new file mode 100644 index 0000000000..be601e4495 --- /dev/null +++ b/v3/src/components/axis/helper-models/date-axis-helper.ts @@ -0,0 +1,437 @@ +import { Selection } from "d3" +import { AxisHelper, IAxisHelperArgs } from "./axis-helper" +import { MutableRefObject } from "react" +import { kAxisGap, kAxisTickLength, kDefaultFontHeight } from "../../graph/graphing-types" +import { isDateAxisModel, IDateAxisModel } from "../models/axis-model" +import {convertToDate, createDate, determineLevels, EDateTimeLevel, shortMonthNames} + from "../../../utilities/date-utils" +import { measureTextExtent } from "../../../hooks/use-measure-text" + +type ILabelDateAndString = { + labelDate: Date + labelString: string +} + +const getLevelLabelForValue = (level: EDateTimeLevel, date: Date | null): ILabelDateAndString => { + let labelString = '', + labelDate: Date = new Date() + if (date) { + const year = date.getFullYear(), + month = date.getMonth() + + if (level === EDateTimeLevel.eYear) { + labelString = String(year) + labelDate = new Date(year, 1, 1) + } else if (level === EDateTimeLevel.eMonth) { + labelDate = new Date(year, month, 1) + labelString = `${shortMonthNames[month]}, ${year}` + } else { + // From below here we'll need the date and its short label + const day = date.getDate() + let hour = 0, + minute = 0, + second = 0 + + if (level < EDateTimeLevel.eDay) { + hour = date.getHours() + if (level < EDateTimeLevel.eHour) { + minute = date.getMinutes() + if (level < EDateTimeLevel.eMinute) { + second = date.getSeconds() + } + } + } + labelDate = new Date(year, month, day, hour, minute, second) + labelString = labelDate.toLocaleDateString() + } + } + + return {labelString, labelDate} +} + +const findFirstDateAboveOrAtLevel = (level: number, date: Date, gap:number) => { + let resultDate: Date = new Date(), + labelString = '', + year = date.getFullYear(), + month = date.getMonth(), + dayOfMonth = date.getDate(), + hour = date.getHours(), + minute = date.getMinutes(), + second = date.getSeconds() + switch (level) { + case EDateTimeLevel.eYear: + year = Math.ceil(year / gap) * gap + resultDate = new Date(year, 0, 1) + if (resultDate.valueOf() < date.valueOf()) { + resultDate = new Date(++year, 0, 1) + } + labelString = String(year) + break + case EDateTimeLevel.eMonth: + resultDate = new Date(year, month, 1) + if (resultDate.valueOf() < date.valueOf()) { + month++ + if (month > 12) { + year++ + month = 1 + } + resultDate = new Date(year, month, 1) + } + labelString = shortMonthNames[resultDate.getMonth()] + break + case EDateTimeLevel.eDay: + resultDate = new Date(year, month, dayOfMonth) + if (resultDate.valueOf() < date.valueOf()) { + dayOfMonth++ + resultDate = new Date(year, month, dayOfMonth) + } + dayOfMonth = resultDate.getDate() + labelString = String(dayOfMonth) + break + case EDateTimeLevel.eHour: + resultDate = new Date(year, month, dayOfMonth, hour) + if (resultDate.valueOf() < date.valueOf()) { + hour++ + resultDate = new Date(year, month, dayOfMonth, hour) + } + hour = resultDate.getHours() + labelString = `${hour}:00` + break + case EDateTimeLevel.eMinute: + resultDate = new Date(year, month, dayOfMonth, hour, minute) + if (resultDate.valueOf() < date.valueOf()) { + resultDate = new Date(year, month, dayOfMonth, hour, ++minute) + } + minute = resultDate.getMinutes() + labelString = String(minute) + break + case EDateTimeLevel.eSecond: + resultDate = new Date(year, month, dayOfMonth, hour, minute, second) + if (resultDate.valueOf() < date.valueOf()) { + resultDate = new Date(year, month, dayOfMonth, hour, minute, second) + } + second = resultDate.getSeconds() + labelString = String(second) + break + default: + } + + return {labelString, labelDate: resultDate} +} + +const getNextLevelLabelForValue = (level: EDateTimeLevel, date: Date | null) => { + let tNextDate: Date = new Date() + if (date) { + let year = date.getFullYear(), + month = date.getMonth(), + dayOfMonth = date.getDate(), + hour = date.getHours(), + minute = date.getMinutes(), + second = date.getSeconds() + switch (level) { + case EDateTimeLevel.eYear: + year++ + break + case EDateTimeLevel.eMonth: + month++ + if (month > 12) { + year++ + month = 1 + } + break + case EDateTimeLevel.eDay: + dayOfMonth++ + break + case EDateTimeLevel.eHour: + hour++ + if (hour > 24) { + dayOfMonth++ + hour = 0 + } + break + case EDateTimeLevel.eMinute: + minute++ + if (minute > 60) { + hour++ + minute = 0 + } + break + case EDateTimeLevel.eSecond: + second++ + if (second > 60) { + minute++ + second = 0 + } + break + default: + } + + if (level <= EDateTimeLevel.eHour) { // It was either year or month level + tNextDate = new Date(year, month, dayOfMonth, hour, minute, second) + } else { + tNextDate = new Date(year, month, dayOfMonth) + } + } + + return getLevelLabelForValue(level, tNextDate) +} + +export interface IDateAxisHelperArgs extends IAxisHelperArgs { + subAxisSelectionRef: MutableRefObject | undefined> +} + +export class DateAxisHelper extends AxisHelper { + subAxisSelectionRef: MutableRefObject | undefined> + maxNumberExtent: number = kDefaultFontHeight + + constructor(props:IDateAxisHelperArgs) { + super(props) + this.subAxisSelectionRef = props.subAxisSelectionRef + } + + render() { + if (!this.subAxisSelectionRef.current || !isDateAxisModel(this.axisModel)) return + + const drawTicks = () => { + + const dataToCoordinate = (dataValue: number) => { + const proportion = (dataValue - lowerBoundsSeconds) / (upperBoundsSeconds - lowerBoundsSeconds) + return isVertical ? rangeMax - proportion * this.subAxisLength : rangeMin + proportion * this.subAxisLength + } + + const dateAxisModel = this.axisModel as IDateAxisModel, + lower = dateAxisModel.min, + upper = dateAxisModel.max + if (lower === upper) return + + const drawOuterLabels = (level: EDateTimeLevel) => { + + const drawOneOuterLabel = (iCoord: number, iRefPoint: { x: number, y: number }, iLabelString: string, + iRotation: number, iAnchor: string) => { + sAS.append('text') + .attr('x', isVertical ? iRefPoint.x : iCoord) + .attr('y', isVertical ? iCoord : iRefPoint.y) + .attr('text-anchor', iAnchor) + .attr('transform', `rotate(${iRotation}, ${isVertical ? iRefPoint.x + kDefaultFontHeight : iCoord}, + ${isVertical ? iCoord : iRefPoint.y})`) + .text(iLabelString) + } + + const rotation = isVertical ? -90 : 0, + offset = kAxisGap + kAxisTickLength + 2.5 * kDefaultFontHeight, + refPoint = isVertical ? {x: -offset, y: 0} : {x: 0, y: offset} + let thisLabel = getLevelLabelForValue(level, convertToDate(lower)) + const firstLabelString = thisLabel.labelString + let done = false, + somethingDrawn = false, + coord: number + while (!done) { + const nextLabel = getNextLevelLabelForValue(level, thisLabel.labelDate) + if (!nextLabel.labelDate) { + done = true + } + if (thisLabel.labelDate > upperBoundsDate) { + if (!somethingDrawn) { + // This is the special case of one outer label spanning the whole axis + coord = dataToCoordinate((lowerBoundsSeconds + upperBoundsSeconds) / 2) + drawOneOuterLabel(coord, refPoint, firstLabelString, rotation, 'middle') + } + done = true // Nothing more to do + } else if (somethingDrawn || (nextLabel.labelDate < upperBoundsDate)) { // This is the normal case + coord = dataToCoordinate(Math.max(thisLabel.labelDate.valueOf() / 1000, lowerBoundsSeconds)) + let oKToDraw = true + if (thisLabel.labelDate < lowerBoundsDate) { + // If drawing this label will overlap the next label, then don't draw it + const textExtent = measureTextExtent(nextLabel.labelString), + tNextCoord = dataToCoordinate(nextLabel.labelDate.valueOf() / 1000) + if (textExtent.width > 7 * Math.abs(tNextCoord - coord) / 8) { + oKToDraw = false + } + } + if (oKToDraw) { + drawOneOuterLabel(coord, refPoint, thisLabel.labelString, rotation, 'start') + } + somethingDrawn = true // even if we really didn't + } + thisLabel = nextLabel + } + } + + const drawInnerLabels = (level: EDateTimeLevel, iIncrement: number) => { + + const getLabelForIncrementedDateAtLevel = (iLevel: number, iDate: Date, iIncrementBy: number) => { + let tResultDate: Date = new Date(), + tLabelString = '' + let tYear = iDate.getFullYear(), + tMonth = iDate.getMonth(), + tDayOfMonth = iDate.getDate(), + tHour = iDate.getHours(), + tMinute = iDate.getMinutes(), + tSecond = iDate.getSeconds(), + tMinuteString, tSecondString + switch (iLevel) { + case EDateTimeLevel.eYear: + tYear += iIncrementBy + tResultDate = new Date(tYear, 0, 1) + tLabelString = String(tYear) + break + case EDateTimeLevel.eMonth: + tMonth += iIncrementBy + while (tMonth > 12) { + tYear++ + tMonth -= 12 + } + tResultDate = new Date(tYear, tMonth, 1) + tLabelString = shortMonthNames[tResultDate.getMonth()] + break + case EDateTimeLevel.eDay: + tDayOfMonth += iIncrementBy + tResultDate = new Date(tYear, tMonth, tDayOfMonth) + tDayOfMonth = tResultDate.getDate() + tLabelString = String(tDayOfMonth) + break + case EDateTimeLevel.eHour: + tHour += iIncrementBy + while (tHour > 24) { + tDayOfMonth++ + tHour -= 24 + } + tResultDate = new Date(tYear, tMonth, tDayOfMonth, tHour) + tHour = tResultDate.getHours() + tLabelString = `${tHour}:00` + break + case EDateTimeLevel.eMinute: + tMinute += iIncrementBy + tResultDate = new Date(tYear, tMonth, tDayOfMonth, tHour, tMinute) + tMinute = tResultDate.getMinutes() + tMinuteString = tMinute < 10 ? `0${tMinute}` : String(tMinute) + tLabelString = `${tHour}:${tMinuteString}` + break + case EDateTimeLevel.eSecond: + tSecond += iIncrementBy + tResultDate = new Date(tYear, tMonth, tDayOfMonth, tHour, tMinute, tSecond) + tSecond = tResultDate.getSeconds() + tMinuteString = tMinute < 10 ? `0${tMinute}` : String(tMinute) + tSecondString = tSecond < 10 ? `0${tSecond}` : String(tSecond) + tLabelString = `${tHour}:${tMinuteString}:${tSecondString}` + break + default: + } + + return {labelString: tLabelString, labelDate: tResultDate} + } + + const findDrawValueModulus = (innerLevel: number, firstDateLabel: ILabelDateAndString) => { + let interval = 1, + foundWorkableInterval = false + while (!foundWorkableInterval) { + let date = firstDateLabel.labelDate, + dateInSeconds = date.valueOf() / 1000, + label = firstDateLabel.labelString, + currentDateLabel = firstDateLabel, + lastPixelUsed = Number.MAX_VALUE, + firstTime = true, + foundCollision = false + while (!foundCollision && (dateInSeconds < upperBoundsSeconds)) { + const pixel = dataToCoordinate(dateInSeconds), + textExtent = measureTextExtent(label), + halfWidth = 5 * textExtent.width / 8, // Overestimation creates gap between labels + overlapped = !isVertical ? pixel - halfWidth < lastPixelUsed : pixel + halfWidth > lastPixelUsed + if (firstTime || !overlapped) { + firstTime = false + lastPixelUsed = pixel + (isVertical ? -halfWidth : halfWidth) + currentDateLabel = getLabelForIncrementedDateAtLevel(innerLevel, + date, interval * iIncrement) + date = currentDateLabel.labelDate + dateInSeconds = date.valueOf() / 1000 + label = currentDateLabel.labelString + } else { + foundCollision = true + } + } + if (foundCollision) { + interval++ + } else { + foundWorkableInterval = true + } + } + return interval + } + + const drawTickAndLabel = (iDateLabel: ILabelDateAndString, drawLabel: boolean) => { + const refPoint = {x: 0, y: 0} + let rotation = 0, + pixel = dataToCoordinate(iDateLabel.labelDate.valueOf() / 1000) + if (!isVertical) { + refPoint.y = kAxisTickLength + kAxisGap + kDefaultFontHeight // y-value + pixel += rangeMin + refPoint.x = pixel + sAS.append('line') + .attr('style', 'stroke: black') + .attr('x1', refPoint.x) + .attr('x2', refPoint.x) + .attr('y1', 0) + .attr('y2', kAxisTickLength) + } else { // 'vertical' + rotation = -90 // x-value + refPoint.x = -kAxisTickLength - kAxisGap + refPoint.y = pixel + sAS.append('line') + .attr('style', 'stroke: black') + .attr('x1', 0) + .attr('x2', -kAxisTickLength) + .attr('y1', refPoint.y) + .attr('y2', refPoint.y) + } + + if (drawLabel) { + sAS.append('text') + .attr('x', refPoint.x) + .attr('y', refPoint.y) + .attr('text-anchor', 'middle') + .attr('transform', `rotate(${rotation}, ${isVertical ? refPoint.x : refPoint.y}, + ${isVertical ? refPoint.y : 0})`) + .text(iDateLabel.labelString) + } + } + + let dateLabel = findFirstDateAboveOrAtLevel(level, lowerBoundsDate, iIncrement), + tCounter = 0 + const drawValueModulus = findDrawValueModulus(level, dateLabel) + + // To get the right formatting we have to call as below. Use 0 as increment for first time through. + dateLabel = getLabelForIncrementedDateAtLevel(level, dateLabel.labelDate, 0) + + while (dateLabel.labelDate < upperBoundsDate) { + drawTickAndLabel(dateLabel, tCounter === 0) + tCounter = (tCounter + 1) % drawValueModulus + dateLabel = getLabelForIncrementedDateAtLevel(level, dateLabel.labelDate, iIncrement) + } + + } + + const levels = determineLevels(lowerBoundsMS, upperBoundsMS), + numLevels = (levels.outerLevel !== levels.innerLevel) ? 2 : 1 + + this.maxNumberExtent = numLevels * kDefaultFontHeight + if (numLevels === 2) { + drawOuterLabels(levels.outerLevel) + } + drawInnerLabels(levels.innerLevel, levels.increment) + } + + const isVertical = this.isVertical, + [lowerBoundsSeconds, upperBoundsSeconds] = this.axisModel.domain, + lowerBoundsMS = 1000 * lowerBoundsSeconds, + lowerBoundsDate = createDate(lowerBoundsSeconds) as Date, + upperBoundsMS = 1000 * upperBoundsSeconds, + upperBoundsDate = createDate(upperBoundsSeconds) as Date, + sAS = this.subAxisSelectionRef.current, + {rangeMin, rangeMax} = this + + sAS.selectAll('*').remove() + + this.renderAxisLine() + drawTicks() + } +} diff --git a/v3/src/components/axis/helper-models/numeric-axis-helper.test.ts b/v3/src/components/axis/helper-models/numeric-axis-helper.test.ts new file mode 100644 index 0000000000..7ed3614ef2 --- /dev/null +++ b/v3/src/components/axis/helper-models/numeric-axis-helper.test.ts @@ -0,0 +1,85 @@ +import { select } from "d3" +import { NumericAxisHelper, INumericAxisHelperArgs } from "./numeric-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(), + call: jest.fn().mockReturnThis(), + transition: jest.fn().mockReturnThis(), + duration: jest.fn().mockReturnThis() + }) +})) + +describe("NumericAxisHelper", () => { + let props: INumericAxisHelperArgs + let numericAxisHelper: NumericAxisHelper + + beforeEach(() => { + props = { + displayModel: { + hasDraggableNumericAxis: jest.fn().mockReturnValue(true), + nonDraggableAxisTicks: jest.fn().mockReturnValue({ tickValues: [], tickLabels: [] }) + } as unknown 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 }), + getAxisLength: jest.fn().mockReturnValue(100) + } as unknown as IAxisLayout, + isAnimating: jest.fn().mockReturnValue(false), + showScatterPlotGridLines: true + } + numericAxisHelper = new NumericAxisHelper(props) + }) + + it("should initialize correctly", () => { + expect(numericAxisHelper.displayModel).toBe(props.displayModel) + expect(numericAxisHelper.subAxisIndex).toBe(props.subAxisIndex) + expect(numericAxisHelper.subAxisElt).toBe(props.subAxisElt) + expect(numericAxisHelper.axisModel).toBe(props.axisModel) + expect(numericAxisHelper.layout).toBe(props.layout) + expect(numericAxisHelper.isAnimating).toBe(props.isAnimating) + expect(numericAxisHelper.showScatterPlotGridLines).toBe(props.showScatterPlotGridLines) + }) + + it("should return correct newRange", () => { + expect(numericAxisHelper.newRange).toEqual([100, 0]) + }) + + // todo: fix this test + it.skip("should render scatter plot grid lines correctly", () => { + numericAxisHelper.renderScatterPlotGridLines() + expect(select).toHaveBeenCalledWith(props.subAxisElt) + expect(select(props.subAxisElt).selectAll).toHaveBeenCalledWith('.zero, .grid') + expect(select(props.subAxisElt).append).toHaveBeenCalledWith('g') + expect(select(props.subAxisElt).attr).toHaveBeenCalledWith('class', 'grid') + expect(select(props.subAxisElt).call).toHaveBeenCalled() + expect(select(props.subAxisElt).selectAll).toHaveBeenCalledWith('text') + expect(select(props.subAxisElt).append).toHaveBeenCalledWith('g') + expect(select(props.subAxisElt).attr).toHaveBeenCalledWith('class', 'zero') + expect(select(props.subAxisElt).call).toHaveBeenCalled() + expect(select(props.subAxisElt).selectAll).toHaveBeenCalledWith('text') + }) + + // todo: fix this test + it.skip("should render correctly", () => { + numericAxisHelper.render() + 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).transition).toHaveBeenCalled() + expect(select(props.subAxisElt).call).toHaveBeenCalled() + expect(select(props.subAxisElt).style).toHaveBeenCalledWith("stroke", "lightgrey") + expect(select(props.subAxisElt).style).toHaveBeenCalledWith("stroke-opacity", "0.7") + }) +}) diff --git a/v3/src/components/axis/helper-models/numeric-axis-helper.ts b/v3/src/components/axis/helper-models/numeric-axis-helper.ts new file mode 100644 index 0000000000..1e93e7df95 --- /dev/null +++ b/v3/src/components/axis/helper-models/numeric-axis-helper.ts @@ -0,0 +1,71 @@ +import { format, ScaleLinear, select } from "d3" +import { between } from "../../../utilities/math-utils" +import { isNumericAxisModel } from "../models/axis-model" +import { transitionDuration } from "../../data-display/data-display-types" +import { computeBestNumberOfTicks } from "../axis-utils" +import { AxisScaleType, otherPlace } from "../axis-types" +import { AxisHelper, IAxisHelperArgs } from "./axis-helper" + +export interface INumericAxisHelperArgs extends IAxisHelperArgs { + showScatterPlotGridLines: boolean +} +export class NumericAxisHelper extends AxisHelper { + showScatterPlotGridLines: boolean + + constructor(props: INumericAxisHelperArgs) { + super(props) + this.showScatterPlotGridLines = props.showScatterPlotGridLines + } + + get newRange() { + return this.isVertical ? [this.rangeMax, this.rangeMin] : [this.rangeMin, this.rangeMax] + } + + renderScatterPlotGridLines() { + const d3Scale: AxisScaleType = this.multiScale?.scale.copy().range(this.newRange) as AxisScaleType, + numericScale = d3Scale as unknown as ScaleLinear + select(this.subAxisElt).selectAll('.zero, .grid').remove() + const tickLength = this.layout.getAxisLength(otherPlace(this.axisPlace)) ?? 0 + select(this.subAxisElt).append('g') + .attr('class', 'grid') + .call(this.axis(numericScale).tickSizeInner(-tickLength)) + select(this.subAxisElt).select('.grid').selectAll('text').remove() + if (between(0, numericScale.domain()[0], numericScale.domain()[1])) { + select(this.subAxisElt).append('g') + .attr('class', 'zero') + .call(this.axis(numericScale).tickSizeInner(-tickLength).tickValues([0])) + select(this.subAxisElt).select('.zero').selectAll('text').remove() + } + } + + render() { + const numericScale = this.multiScale?.scaleType === "linear" + ? this.multiScale.numericScale?.copy().range(this.newRange) as ScaleLinear + : undefined + if (!isNumericAxisModel(this.axisModel) || !numericScale) return + + select(this.subAxisElt).selectAll('*').remove() + this.renderAxisLine() + + const axisScale = this.axis(numericScale).tickSizeOuter(0).tickFormat(format('.9')) + const duration = this.isAnimating() ? transitionDuration : 0 + if (!this.isVertical && this.displayModel.hasDraggableNumericAxis(this.axisModel)) { + axisScale.tickValues(numericScale.ticks(computeBestNumberOfTicks(numericScale))) + } else if (!this.displayModel.hasDraggableNumericAxis(this.axisModel)) { + const formatter = (value: number) => this.multiScale?.formatValueForScale(value) ?? "" + const {tickValues, tickLabels} = this.displayModel.nonDraggableAxisTicks(formatter) + axisScale.tickValues(tickValues) + axisScale.tickFormat((d, i) => tickLabels[i]) + } + select(this.subAxisElt) + .attr("transform", this.initialTransform) + .transition().duration(duration) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore types are incompatible + .call(axisScale).selectAll("line,path") + .style("stroke", "lightgrey") + .style("stroke-opacity", "0.7") + + this.showScatterPlotGridLines && this.renderScatterPlotGridLines() + } +} diff --git a/v3/src/components/axis/hooks/use-axis-provider-context.ts b/v3/src/components/axis/hooks/use-axis-provider-context.ts index 64403e5945..4a6596eb37 100644 --- a/v3/src/components/axis/hooks/use-axis-provider-context.ts +++ b/v3/src/components/axis/hooks/use-axis-provider-context.ts @@ -1,10 +1,10 @@ import { createContext, useContext } from "react" -import { IAxisModel, INumericAxisModel, isNumericAxisModel } from "../models/axis-model" +import { IBaseNumericAxisModel, IAxisModel, isNumericAxisModel } from "../models/axis-model" import { AxisPlace } from "../axis-types" export interface IAxisProvider { getAxis: (place: AxisPlace) => IAxisModel | undefined - getNumericAxis: (place: AxisPlace) => INumericAxisModel | undefined + getNumericAxis: (place: AxisPlace) => IBaseNumericAxisModel | undefined } const kDefaultAxisProvider = { getAxis: () => undefined, diff --git a/v3/src/components/axis/hooks/use-axis.test.tsx b/v3/src/components/axis/hooks/use-axis.test.tsx index f7ca626494..b694ccbcd4 100644 --- a/v3/src/components/axis/hooks/use-axis.test.tsx +++ b/v3/src/components/axis/hooks/use-axis.test.tsx @@ -4,7 +4,7 @@ import { Instance, types } from "mobx-state-tree" import React from "react" import { SliderAxisLayout } from "../../slider/slider-layout" import { AxisLayoutContext } from "../models/axis-layout-context" -import { INumericAxisModel, NumericAxisModel } from "../models/axis-model" +import { IBaseNumericAxisModel, NumericAxisModel } from "../models/axis-model" import {IUseAxis, useAxis} from "./use-axis" import { AxisProviderContext } from "./use-axis-provider-context" @@ -24,7 +24,7 @@ interface ITestAxisProvider extends Instance {} describe("useAxis", () => { let provider: ITestAxisProvider - let axisModel: INumericAxisModel + let axisModel: IBaseNumericAxisModel let layout: SliderAxisLayout let axisElt: SVGGElement let useAxisOptions: IUseAxis diff --git a/v3/src/components/axis/hooks/use-axis.ts b/v3/src/components/axis/hooks/use-axis.ts index feb1ab121c..ade1468175 100644 --- a/v3/src/components/axis/hooks/use-axis.ts +++ b/v3/src/components/axis/hooks/use-axis.ts @@ -1,15 +1,15 @@ -import {ScaleBand, ScaleLinear, scaleLinear, scaleOrdinal} from "d3" -import {reaction} from "mobx" -import {isAlive} from "mobx-state-tree" -import {useCallback, useEffect, useRef} from "react" -import {mstAutorun} from "../../../utilities/mst-autorun" -import {graphPlaceToAttrRole} from "../../data-display/data-display-types" -import {maxWidthOfStringsD3} from "../../data-display/data-display-utils" -import {useDataConfigurationContext} from "../../data-display/hooks/use-data-configuration-context" -import {AxisPlace, AxisScaleType, axisGap} from "../axis-types" -import {useAxisLayoutContext} from "../models/axis-layout-context" -import {IAxisModel, isNumericAxisModel} from "../models/axis-model" -import {collisionExists, getStringBounds, isScaleLinear} from "../axis-utils" +import { ScaleBand, ScaleLinear, scaleLinear, scaleOrdinal } from "d3" +import { reaction } from "mobx" +import { isAlive } from "mobx-state-tree" +import { useCallback, useEffect, useRef } from "react" +import { mstAutorun } from "../../../utilities/mst-autorun" +import { graphPlaceToAttrRole } from "../../data-display/data-display-types" +import { maxWidthOfStringsD3 } from "../../data-display/data-display-utils" +import { useDataConfigurationContext } from "../../data-display/hooks/use-data-configuration-context" +import { AxisPlace, AxisScaleType, axisGap } from "../axis-types" +import { useAxisLayoutContext } from "../models/axis-layout-context" +import { IAxisModel, isDateAxisModel, isNumericAxisModel } from "../models/axis-model" +import { collisionExists, getNumberOfLevelsForDateAxis, getStringBounds, isScaleLinear } from "../axis-utils" import { useAxisProviderContext } from "./use-axis-provider-context" import { useDataDisplayModelContext } from "../../data-display/hooks/use-data-display-model" import { IDataDisplayContentModel } from "../../data-display/models/data-display-content-model" @@ -31,14 +31,14 @@ interface IGetTicksProps { } const getTicks = (props: IGetTicksProps) => { - const { d3Scale, multiScale, pointDisplayType, displayModel } = props + const {d3Scale, multiScale, pointDisplayType, displayModel} = props if (!isScaleLinear(d3Scale)) return [] - let ticks: string[] = [] + let ticks: string[] if (pointDisplayType === "bins" && displayModel && multiScale) { const formatter = (value: number) => multiScale.formatValueForScale(value) - const { tickValues, tickLabels } = displayModel.nonDraggableAxisTicks(formatter) - ticks = tickValues.map((tickValue, i) => { + const {tickValues, tickLabels} = displayModel.nonDraggableAxisTicks(formatter) + ticks = tickValues.map((_tickValue, i) => { return tickLabels[i] }) } else { @@ -48,7 +48,7 @@ const getTicks = (props: IGetTicksProps) => { return ticks } -export const useAxis = ({ axisPlace, axisTitle = "", centerCategoryLabels }: IUseAxis) => { +export const useAxis = ({axisPlace, axisTitle = "", centerCategoryLabels}: IUseAxis) => { const layout = useAxisLayoutContext(), displayModel = useDataDisplayModelContext(), axisProvider = useAxisProviderContext(), @@ -98,7 +98,7 @@ export const useAxis = ({ axisPlace, axisTitle = "", centerCategoryLabels }: IUs let ticks: string[] = [] switch (type) { case 'numeric': { - ticks = getTicks({ d3Scale, multiScale, pointDisplayType, displayModel }) + ticks = getTicks({d3Scale, multiScale, pointDisplayType, displayModel}) desiredExtent += ['left', 'rightNumeric'].includes(axisPlace) ? Math.max(getStringBounds(ticks[0]).width, getStringBounds(ticks[ticks.length - 1]).width) + axisGap : numbersHeight + axisGap @@ -108,85 +108,92 @@ export const useAxis = ({ axisPlace, axisTitle = "", centerCategoryLabels }: IUs desiredExtent += collision ? maxLabelExtent : getStringBounds().height break } - } - return desiredExtent - }, [dataConfiguration, axisPlace, axisTitle, multiScale, ordinalScale, categories, centerCategoryLabels, attrRole, - type, displayModel]) - - // update d3 scale and axis when scale type changes - useEffect(() => { - if (axisModel) { - const disposer = reaction( - () => { - const {place: aPlace, scale: scaleType} = axisModel - return {place: aPlace, scaleType} - }, - ({place: aPlace, scaleType}) => { - layout.getAxisMultiScale(aPlace)?.setScaleType(scaleType) - }, {name: "useAxis [scaleType]"} - ) - return () => disposer() - } - }, [isNumeric, axisModel, layout]) - - // update d3 scale and axis when axis domain changes - useEffect(function installDomainSync() { - if (isNumeric) { - return mstAutorun(() => { - const _axisModel = axisProvider?.getNumericAxis?.(axisPlace) - if (_axisModel && !isAlive(_axisModel)) { - console.warn("useAxis.installDomainSync skipping sync of defunct axis model") - return + case 'date': { + if (isDateAxisModel(axisModel)) { + desiredExtent += getNumberOfLevelsForDateAxis(axisModel.min, axisModel.max) * numbersHeight + axisGap } - _axisModel?.domain && multiScale?.setNumericDomain(_axisModel?.domain) - layout.setDesiredExtent(axisPlace, computeDesiredExtent()) - }, { name: "useAxis.installDomainSync" }, axisProvider) + break + } } - // Note axisModelChanged as a dependent. Shouldn't be necessary. - }, [axisModelChanged, isNumeric, multiScale, axisPlace, layout, computeDesiredExtent, axisProvider]) + return desiredExtent +}, [dataConfiguration, axisPlace, axisTitle, multiScale, ordinalScale, categories, centerCategoryLabels, + attrRole, type, displayModel, axisModel] +) - // update d3 scale and axis when layout/range changes - useEffect(() => { +// update d3 scale and axis when scale type changes +useEffect(() => { + if (axisModel) { const disposer = reaction( () => { - return layout.getAxisLength(axisPlace) + const {place: aPlace, scale: scaleType} = axisModel + return {place: aPlace, scaleType} }, - () => { - layout.setDesiredExtent(axisPlace, computeDesiredExtent()) - }, {name: "useAxis [axisRange]"} + ({place: aPlace, scaleType}) => { + layout.getAxisMultiScale(aPlace)?.setScaleType(scaleType) + }, {name: "useAxis [scaleType]"} ) return () => disposer() - }, [axisModel, layout, axisPlace, computeDesiredExtent]) + } +}, [isNumeric, axisModel, layout]) - // update d3 scale and axis when pointDisplayType changes - useEffect(() => { - const disposer = reaction( - () => { - return displayModel.pointDisplayType - }, - () => { - layout.setDesiredExtent(axisPlace, computeDesiredExtent()) - }, {name: "useAxis [pointDisplayType]"} - ) - return () => disposer() - }, [axisModel, layout, axisPlace, computeDesiredExtent, displayModel.pointDisplayType]) +// update d3 scale and axis when axis domain changes +useEffect(function installDomainSync() { + if (isNumeric) { + return mstAutorun(() => { + const _axisModel = axisProvider?.getNumericAxis?.(axisPlace) + if (_axisModel && !isAlive(_axisModel)) { + console.warn("useAxis.installDomainSync skipping sync of defunct axis model") + return + } + _axisModel?.domain && multiScale?.setNumericDomain(_axisModel?.domain) + layout.setDesiredExtent(axisPlace, computeDesiredExtent()) + }, {name: "useAxis.installDomainSync"}, axisProvider) + } + // Note axisModelChanged as a dependent. Shouldn't be necessary. +}, [axisModelChanged, isNumeric, multiScale, axisPlace, layout, computeDesiredExtent, axisProvider]) - // Set desired extent when things change - useEffect(() => { - layout.setDesiredExtent(axisPlace, computeDesiredExtent()) - }, [computeDesiredExtent, axisPlace, attributeID, layout]) +// update d3 scale and axis when layout/range changes +useEffect(() => { + const disposer = reaction( + () => { + return layout.getAxisLength(axisPlace) + }, + () => { + layout.setDesiredExtent(axisPlace, computeDesiredExtent()) + }, {name: "useAxis [axisRange]"} + ) + return () => disposer() +}, [axisModel, layout, axisPlace, computeDesiredExtent]) - // Set desired extent when repetitions of my multiscale changes - useEffect(() => { - const disposer = reaction( - () => { - return layout.getAxisMultiScale(axisPlace)?.repetitions - }, - () => { - layout.setDesiredExtent(axisPlace, computeDesiredExtent()) - }, {name: "useAxis [axis repetitions]"} - ) - return () => disposer() - }, [computeDesiredExtent, axisPlace, layout]) +// update d3 scale and axis when pointDisplayType changes +useEffect(() => { + const disposer = reaction( + () => { + return displayModel.pointDisplayType + }, + () => { + layout.setDesiredExtent(axisPlace, computeDesiredExtent()) + }, {name: "useAxis [pointDisplayType]"} + ) + return () => disposer() +}, [axisModel, layout, axisPlace, computeDesiredExtent, displayModel.pointDisplayType]) + +// Set desired extent when things change +useEffect(() => { + layout.setDesiredExtent(axisPlace, computeDesiredExtent()) +}, [computeDesiredExtent, axisPlace, attributeID, layout]) + +// Set desired extent when repetitions of my multiscale changes +useEffect(() => { + const disposer = reaction( + () => { + return layout.getAxisMultiScale(axisPlace)?.repetitions + }, + () => { + layout.setDesiredExtent(axisPlace, computeDesiredExtent()) + }, {name: "useAxis [axis repetitions]"} + ) + return () => disposer() +}, [computeDesiredExtent, axisPlace, layout]) } diff --git a/v3/src/components/axis/hooks/use-sub-axis.ts b/v3/src/components/axis/hooks/use-sub-axis.ts index 15db552708..b1fb352c06 100644 --- a/v3/src/components/axis/hooks/use-sub-axis.ts +++ b/v3/src/components/axis/hooks/use-sub-axis.ts @@ -1,22 +1,28 @@ -import {BaseType, drag, format, ScaleLinear, select, Selection} from "d3" -import {reaction} from "mobx" -import {useCallback, useEffect, useMemo, useRef} from "react" -import {axisPlaceToAttrRole, transitionDuration} from "../../data-display/data-display-types" -import {useDataDisplayAnimation} from "../../data-display/hooks/use-data-display-animation" -import {AxisBounds, AxisPlace, axisPlaceToAxisFn, AxisScaleType, otherPlace} from "../axis-types" -import {useAxisLayoutContext} from "../models/axis-layout-context" -import {isCategoricalAxisModel, isNumericAxisModel} from "../models/axis-model" -import {isVertical} from "../../axis-graph-shared" -import {between} from "../../../utilities/math-utils" -import {mstAutorun} from "../../../utilities/mst-autorun" -import {isAliveSafe} from "../../../utilities/mst-utils" -import {kAxisTickLength} from "../../graph/graphing-types" -import {DragInfo, collisionExists, computeBestNumberOfTicks, getCategoricalLabelPlacement, - getCoordFunctions, IGetCoordFunctionsProps} from "../axis-utils" -import { useAxisProviderContext } from "./use-axis-provider-context" -import { useDataDisplayModelContext } from "../../data-display/hooks/use-data-display-model" +import { BaseType, drag, select, Selection } from "d3" +import { reaction } from "mobx" +import { mstAutorun } from "../../../utilities/mst-autorun" import { mstReaction } from "../../../utilities/mst-reaction" +import { useCallback, useEffect, useMemo, useRef } from "react" +import { axisPlaceToAttrRole } from "../../data-display/data-display-types" +import { useDataDisplayAnimation } from "../../data-display/hooks/use-data-display-animation" +import { AxisPlace } from "../axis-types" +import { useAxisLayoutContext } from "../models/axis-layout-context" +import { + IAxisModel, + isBaseNumericAxisModel, + isCategoricalAxisModel, + isNumericAxisModel +} from "../models/axis-model" +import { isVertical } from "../../axis-graph-shared" +import { isAliveSafe } from "../../../utilities/mst-utils" import { setNiceDomain } from "../../graph/utilities/graph-utils" +import { DragInfo } from "../axis-utils" +import { useAxisProviderContext } from "./use-axis-provider-context" +import { useDataDisplayModelContext } from "../../data-display/hooks/use-data-display-model" +import { EmptyAxisHelper } from "../helper-models/axis-helper" +import { NumericAxisHelper } from "../helper-models/numeric-axis-helper" +import { CatObject, CategoricalAxisHelper } from "../helper-models/categorical-axis-helper" +import { DateAxisHelper } from "../helper-models/date-axis-helper" export interface IUseSubAxis { subAxisIndex: number @@ -26,11 +32,6 @@ export interface IUseSubAxis { centerCategoryLabels: boolean } -interface CatObject { - cat: string - index: number -} - export const useSubAxis = ({ subAxisIndex, axisPlace, subAxisElt, showScatterPlotGridLines, centerCategoryLabels }: IUseSubAxis) => { @@ -39,7 +40,7 @@ export const useSubAxis = ({ dataConfig = displayModel.dataConfiguration, {isAnimating, stopAnimation} = useDataDisplayAnimation(), axisProvider = useAxisProviderContext(), - axisModel = axisProvider.getAxis?.(axisPlace), + axisModel = axisProvider.getAxis?.(axisPlace) as IAxisModel, isNumeric = isNumericAxisModel(axisModel), isCategorical = isCategoricalAxisModel(axisModel), multiScaleChangeCount = layout.getAxisMultiScale(axisModel?.place ?? 'bottom')?.changeCount ?? 0, @@ -58,6 +59,32 @@ export const useSubAxis = ({ swapInProgress = useRef(false), subAxisSelectionRef = useRef>(), categoriesSelectionRef = useRef>(), + axisHelper = useMemo(() => { + const helperProps = + {displayModel, subAxisIndex, subAxisElt, axisModel, layout, isAnimating} + let helper: EmptyAxisHelper | NumericAxisHelper | CategoricalAxisHelper | undefined = undefined + if (axisModel) { + switch (axisModel.type) { + case 'empty': + helper = new EmptyAxisHelper(helperProps) + break + case 'numeric': + helper = new NumericAxisHelper( + { ... helperProps, showScatterPlotGridLines}) + break + case 'categorical': + helper = new CategoricalAxisHelper( + { ...helperProps, centerCategoryLabels, dragInfo, + subAxisSelectionRef, categoriesSelectionRef, swapInProgress }) + break + case 'date': + subAxisSelectionRef.current = subAxisElt ? select(subAxisElt) : undefined + helper = new DateAxisHelper({...helperProps, subAxisSelectionRef}) + } + } + return helper + }, [displayModel, subAxisIndex, subAxisElt, axisModel, layout, isAnimating, + showScatterPlotGridLines, centerCategoryLabels]), renderSubAxis = useCallback(() => { const _axisModel = axisProvider.getAxis?.(axisPlace) @@ -68,169 +95,8 @@ export const useSubAxis = ({ const multiScale = layout.getAxisMultiScale(axisPlace) if (!multiScale) return // no scale, no axis (But this shouldn't happen) - const subAxisLength = multiScale?.cellLength ?? 0, - rangeMin = subAxisIndex * subAxisLength, - rangeMax = rangeMin + subAxisLength, - axisIsVertical = isVertical(axisPlace), - axis = axisPlaceToAxisFn(axisPlace), - type = _axisModel?.type, - axisBounds = layout.getComputedBounds(axisPlace) as AxisBounds, - newRange = axisIsVertical ? [rangeMax, rangeMin] : [rangeMin, rangeMax], - d3Scale: AxisScaleType = multiScale.scale.copy().range(newRange) as AxisScaleType, - initialTransform = (axisPlace === 'left') - ? `translate(${axisBounds.left + axisBounds.width}, ${axisBounds.top})` - : (axisPlace === 'top') - ? `translate(${axisBounds.left}, ${axisBounds.top + axisBounds.height})` - : `translate(${axisBounds.left}, ${axisBounds.top})` - - const renderEmptyAxis = () => { - select(subAxisElt).selectAll('*').remove() - select(subAxisElt) - .attr("transform", initialTransform) - .append('line') - .attr('x1', 0) - .attr('x2', axisIsVertical ? 0 : subAxisLength) - .attr('y1', 0) - .attr('y2', axisIsVertical ? subAxisLength : 0) - .style("stroke", "lightgrey") - .style("stroke-opacity", "0.7") - }, - renderNumericAxis = () => { - const numericScale = multiScale.scaleType === "linear" - ? multiScale.numericScale?.copy().range(newRange) as ScaleLinear - : undefined - if (!isNumericAxisModel(axisModel) || !numericScale) return - select(subAxisElt).selectAll('*').remove() - const axisScale = axis(numericScale).tickSizeOuter(0).tickFormat(format('.9')) - const duration = isAnimating() ? transitionDuration : 0 - if (!axisIsVertical && displayModel.hasDraggableNumericAxis(axisModel)) { - axisScale.tickValues(numericScale.ticks(computeBestNumberOfTicks(numericScale))) - } else if (!displayModel.hasDraggableNumericAxis(axisModel)) { - const formatter = (value: number) => multiScale.formatValueForScale(value) - const { tickValues, tickLabels } = displayModel.nonDraggableAxisTicks(formatter) - axisScale.tickValues(tickValues) - axisScale.tickFormat((d, i) => tickLabels[i]) - } - select(subAxisElt) - .attr("transform", initialTransform) - .transition().duration(duration) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore types are incompatible - .call(axisScale).selectAll("line,path") - .style("stroke", "lightgrey") - .style("stroke-opacity", "0.7") - }, - - renderScatterPlotGridLines = () => { - if (axis) { - const numericScale = d3Scale as unknown as ScaleLinear - select(subAxisElt).selectAll('.zero, .grid').remove() - const tickLength = layout.getAxisLength(otherPlace(axisPlace)) ?? 0 - select(subAxisElt).append('g') - .attr('class', 'grid') - .call(axis(numericScale).tickSizeInner(-tickLength)) - select(subAxisElt).select('.grid').selectAll('text').remove() - if (between(0, numericScale.domain()[0], numericScale.domain()[1])) { - select(subAxisElt).append('g') - .attr('class', 'zero') - .call(axis(numericScale).tickSizeInner(-tickLength).tickValues([0])) - select(subAxisElt).select('.zero').selectAll('text').remove() - } - } - }, - - renderCategoricalSubAxis = () => { - if (!(subAxisSelectionRef.current && categoriesSelectionRef.current)) return - - const categorySet = multiScale?.categorySet, - dividerLength = layout.getAxisLength(otherPlace(axisPlace)) ?? 0, - isRightCat = axisPlace === 'rightCat', - isTop = axisPlace === 'top', - role = axisPlaceToAttrRole[axisPlace], - categories: string[] = dataConfig?.categoryArrayForAttrRole(role) ?? [], - numCategories = categories.length, - hasCategories = !(categories.length === 1 && categories[0] === "__main__"), - bandWidth = subAxisLength / numCategories, - collision = collisionExists({bandWidth, categories, centerCategoryLabels}), - {rotation, textAnchor} = getCategoricalLabelPlacement(axisPlace, centerCategoryLabels, - collision), - duration = (isAnimating() && !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 = axisIsVertical ? 'vertical' : 'horizontal' - dI.labelOrientation = axisIsVertical ? (collision ? 'horizontal' : 'vertical') - : (collision ? 'vertical' : 'horizontal') - - const sAS = subAxisSelectionRef.current - - sAS.attr("transform", initialTransform) - .select('line') - .attr('x1', axisIsVertical ? 0 : rangeMin) - .attr('x2', axisIsVertical ? 0 : rangeMax) - .attr('y1', axisIsVertical ? rangeMin : 0) - .attr('y2', axisIsVertical ? rangeMax : 0) - - const props: IGetCoordFunctionsProps = { - numCategories, centerCategoryLabels, collision, axisIsVertical, rangeMin, rangeMax, - subAxisLength, isRightCat, isTop, dragInfo - }, - fns = getCoordFunctions(props) - - hasCategories && categoriesSelectionRef.current - .join( - enter => enter, - update => { - update.select('.tick') - .attr('x1', (d, i) => fns.getTickX(i)) - .attr('x2', (d, i) => axisIsVertical - ? (isRightCat ? 1 : -1) * kAxisTickLength : fns.getTickX(i)) - .attr('y1', (d, i) => fns.getTickY(i)) - .attr('y2', (d, i) => axisIsVertical - ? fns.getTickY(i) : (isTop ? -1 : 1) * kAxisTickLength) - // divider between groups - update.select('.divider') - .attr('x1', (d, i) => fns.getDividerX(i)) - .attr('x2', (d, i) => axisIsVertical - ? (isRightCat ? -1 : 1) * dividerLength : fns.getDividerX(i)) - .attr('y1', (d, i) => fns.getDividerY(i)) - .attr('y2', (d, i) => axisIsVertical - ? 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 - } - ) - } - - switch (type) { - case 'empty': - renderEmptyAxis() - break - case 'numeric': - renderNumericAxis() - showScatterPlotGridLines && renderScatterPlotGridLines() - break - case 'categorical': - renderCategoricalSubAxis() - break - } - }, [axisProvider, axisPlace, layout, subAxisIndex, subAxisElt, axisModel, isAnimating, displayModel, - dataConfig, centerCategoryLabels, showScatterPlotGridLines]), + axisHelper?.render() + }, [axisProvider, axisPlace, layout, axisHelper]), onDragStart = useCallback((event: any) => { const dI = dragInfo.current @@ -365,16 +231,15 @@ export const useSubAxis = ({ return mstAutorun(() => { const _axisModel = axisProvider?.getAxis?.(axisPlace) if (isAliveSafe(_axisModel)) { - if (isNumericAxisModel(_axisModel)) { - const { domain } = _axisModel || {} + if (isBaseNumericAxisModel(_axisModel)) { + const {domain} = _axisModel || {} layout.getAxisMultiScale(axisPlace)?.setNumericDomain(domain) renderSubAxis() } - } - else if (_axisModel) { + } else if (_axisModel) { console.warn("useSubAxis.installDomainSync skipping sync of defunct axis model") } - }, { name: "useSubAxis.installDomainSync" }, axisProvider) + }, {name: "useSubAxis.installDomainSync"}, axisProvider) }, [axisPlace, axisProvider, layout, renderSubAxis]) // Refresh when category set, if any, changes @@ -404,10 +269,10 @@ export const useSubAxis = ({ const categoryValues = dataConfig?.categoryArrayForAttrRole(role) ?? [] layout.getAxisMultiScale(axisPlace)?.setCategoricalDomain(categoryValues) setupCategories() - } else if (isNumericAxisModel(axisModel)) { + } else if (isBaseNumericAxisModel(axisModel)) { const numericValues = dataConfig?.numericValuesForAttrRole(role) ?? [] layout.getAxisMultiScale(axisPlace)?.setNumericDomain(numericValues) - axisModel && setNiceDomain(numericValues, axisModel) + isBaseNumericAxisModel(axisModel) && setNiceDomain(numericValues, axisModel) } renderSubAxis() }, [axisModel, axisPlace, dataConfig, layout, renderSubAxis, setupCategories]) diff --git a/v3/src/components/axis/models/axis-model.ts b/v3/src/components/axis/models/axis-model.ts index 0f1f8a02ac..9487dbef43 100644 --- a/v3/src/components/axis/models/axis-model.ts +++ b/v3/src/components/axis/models/axis-model.ts @@ -23,6 +23,12 @@ export const AxisModel = types.model("AxisModel", { get isCategorical() { return self.type === "categorical" }, + get isDate() { + return self.type === "date" + }, + get isBaseNumeric() { + return this.isNumeric || this.isDate + }, get isUpdatingDynamically() { return false } @@ -68,16 +74,15 @@ export function isCategoricalAxisModel(axisModel?: IAxisModel): axisModel is ICa return !!axisModel?.isCategorical } -export const NumericAxisModel = AxisModel - .named("NumericAxisModel") +export const BaseNumericAxisModel = AxisModel + .named("BaseNumericAxisModel") .props({ - type: types.optional(types.literal("numeric"), "numeric"), scale: types.optional(types.enumeration([...ScaleTypes]), "linear"), lockZero: false, min: types.number, max: types.number }) - .volatile(self => ({ + .volatile(_self => ({ dynamicMin: undefined as number | undefined, dynamicMax: undefined as number | undefined })) @@ -116,6 +121,20 @@ export const NumericAxisModel = AxisModel self.lockZero = lockZero } })) + +export interface IBaseNumericAxisModel extends Instance {} +export interface IBaseNumericAxisModelSnapshot extends SnapshotIn {} + +export function isBaseNumericAxisModel(axisModel?: IAxisModel): axisModel is IBaseNumericAxisModel { + return !!axisModel?.isBaseNumeric +} + +export const NumericAxisModel = BaseNumericAxisModel + .named("NumericAxisModel") + .props({ + type: types.optional(types.literal("numeric"), "numeric"), + }) + export interface INumericAxisModel extends Instance {} export interface INumericAxisModelSnapshot extends SnapshotIn {} @@ -123,7 +142,19 @@ export function isNumericAxisModel(axisModel?: IAxisModel): axisModel is INumeri return !!axisModel?.isNumeric } -export const AxisModelUnion = types.union(EmptyAxisModel, CategoricalAxisModel, NumericAxisModel) -export type IAxisModelUnion = IEmptyAxisModel | ICategoricalAxisModel | INumericAxisModel +export const DateAxisModel = BaseNumericAxisModel + .named("DateAxisModel") + .props({ + type: types.optional(types.literal("date"), "date"), + }) +export interface IDateAxisModel extends Instance {} +export interface IDateAxisModelSnapshot extends SnapshotIn {} + +export function isDateAxisModel(axisModel?: IAxisModel): axisModel is IDateAxisModel { + return !!axisModel?.isDate +} + +export const AxisModelUnion = types.union(EmptyAxisModel, CategoricalAxisModel, NumericAxisModel, DateAxisModel) +export type IAxisModelUnion = IEmptyAxisModel | ICategoricalAxisModel | INumericAxisModel | IDateAxisModel export type IAxisModelSnapshotUnion = - IEmptyAxisModelSnapshot | ICategoricalAxisModelSnapshot | INumericAxisModelSnapshot + IEmptyAxisModelSnapshot | ICategoricalAxisModelSnapshot | INumericAxisModelSnapshot | IDateAxisModelSnapshot diff --git a/v3/src/components/case-table/case-table.tsx b/v3/src/components/case-table/case-table.tsx index 002e67ac8a..4a62057dc7 100644 --- a/v3/src/components/case-table/case-table.tsx +++ b/v3/src/components/case-table/case-table.tsx @@ -34,7 +34,7 @@ export const CaseTable = observer(function CaseTable({ setNodeRef }: IProps) { useEffect(() => { // disable vertical auto-scroll of table (column headers can't scroll out of view) return registerCanAutoScrollCallback((element, direction) => { - return element !== contentRef.current || direction.y === 0 + return element !== contentRef.current || (direction && direction.y === 0) }) }, []) diff --git a/v3/src/components/data-display/data-display-utils.ts b/v3/src/components/data-display/data-display-utils.ts index deb3c65374..41368328d7 100644 --- a/v3/src/components/data-display/data-display-utils.ts +++ b/v3/src/components/data-display/data-display-utils.ts @@ -212,3 +212,4 @@ export function rectToTreeRect(rect: Rect) { h: rect.height } } + diff --git a/v3/src/components/data-display/data-display-value-utils.ts b/v3/src/components/data-display/data-display-value-utils.ts new file mode 100644 index 0000000000..7a2cff450c --- /dev/null +++ b/v3/src/components/data-display/data-display-value-utils.ts @@ -0,0 +1,13 @@ +import { convertToDate } from "../../utilities/date-utils" +import { IDataSet } from "../../models/data/data-set" + +// For graphs and map legends, we need date values to be returned as numbers +export const dataDisplayGetNumericValue = (dataset: IDataSet | undefined, caseID: string, attrID: string) => { + const attr = dataset?.getAttribute(attrID) + const index = dataset?.getItemIndexForCaseOrItem(caseID) + if (attr?.type === 'date' && index != null) { + const dateInMS = convertToDate(dataset?.getStrValueAtItemIndex(index, attrID))?.valueOf() + return dateInMS ? dateInMS / 1000 : undefined + } + return dataset?.getNumeric(caseID, attrID) +} diff --git a/v3/src/components/data-display/hooks/use-data-display-model.ts b/v3/src/components/data-display/hooks/use-data-display-model.ts index bac865e0ad..08a3ca39ed 100644 --- a/v3/src/components/data-display/hooks/use-data-display-model.ts +++ b/v3/src/components/data-display/hooks/use-data-display-model.ts @@ -1,6 +1,6 @@ import { createContext, useContext } from "react" import { IDataDisplayContentModel } from "../models/data-display-content-model" -import {IAxisModel, isNumericAxisModel} from "../../axis/models/axis-model" +import { IAxisModel, isBaseNumericAxisModel } from "../../axis/models/axis-model" const kDefaultDataDisplayModel = { // required by useDataDisplayAnimation @@ -10,7 +10,7 @@ const kDefaultDataDisplayModel = { // required by AxisProviderContext getAxis: () => undefined, getNumericAxis: () => undefined, - hasDraggableNumericAxis: (axisModel: IAxisModel) => isNumericAxisModel(axisModel), + hasDraggableNumericAxis: (axisModel: IAxisModel) => isBaseNumericAxisModel(axisModel), nonDraggableAxisTicks: (formatter: (value: number) => string) => ({tickValues: [], tickLabels: []}) } as unknown as IDataDisplayContentModel diff --git a/v3/src/components/data-display/models/data-configuration-model.ts b/v3/src/components/data-display/models/data-configuration-model.ts index 6ac6a3fea4..44f1c990d1 100644 --- a/v3/src/components/data-display/models/data-configuration-model.ts +++ b/v3/src/components/data-display/models/data-configuration-model.ts @@ -6,10 +6,12 @@ import { } from "mobx-state-tree" import {applyModelChange} from "../../../models/history/apply-model-change" import {cachedFnWithArgsFactory, onAnyAction} from "../../../utilities/mst-utils" +import { isFiniteNumber } from "../../../utilities/math-utils" import {AttributeType, attributeTypes} from "../../../models/data/attribute" import {DataSet, IDataSet} from "../../../models/data/data-set" import {ICase} from "../../../models/data/data-set-types" import {idOfChildmostCollectionForAttributes} from "../../../models/data/data-set-utils" +import { dataDisplayGetNumericValue } from "../data-display-value-utils" import {ISharedCaseMetadata, SharedCaseMetadata} from "../../../models/shared/shared-case-metadata" import {isSetCaseValuesAction} from "../../../models/data/data-set-actions" import {FilteredCases, IFilteredChangedCases} from "../../../models/data/filtered-cases" @@ -19,7 +21,6 @@ import {CaseData} from "../d3-types" import {AttrRole, TipAttrRoles, graphPlaceToAttrRole} from "../data-display-types" import {GraphPlace} from "../../axis-graph-shared" import { numericSortComparator } from "../../../utilities/data-utils" -import { isFiniteNumber } from "../../../utilities/math-utils" export const AttributeDescription = types .model('AttributeDescription', { @@ -286,10 +287,20 @@ export const DataConfigurationModel = types }) })) .views(self => ({ - numericValuesForAttrRole(role: AttrRole): number[] { - return self.valuesForAttrRole(role).map((aValue: string) => Number(aValue)) - .filter((aValue: number) => isFinite(aValue)) - }, + numericValuesForAttrRole: cachedFnWithArgsFactory({ + key: (role: AttrRole) => role, + calculate: (role: AttrRole) => { + const attrID = self.attributeID(role) + const dataset = self.dataset + const allCaseIDs = Array.from(self.allCaseIDs) + const allValues = attrID + ? allCaseIDs.map((anID: string) => { + const value = dataDisplayGetNumericValue(dataset, anID, attrID) + return isFiniteNumber(value) ? value : null + }) : [] + return allValues.filter(aValue => aValue != null) + } + }), categorySetForAttrRole(role: AttrRole) { if (self.metadata) { const attributeID = self.attributeID(role) || '' @@ -523,6 +534,7 @@ export const DataConfigurationModel = types .actions(self => ({ clearCasesCache() { self.valuesForAttrRole.invalidateAll() + self.numericValuesForAttrRole.invalidateAll() self.categoryArrayForAttrRole.invalidateAll() self.allCasesForCategoryAreSelected.invalidateAll() // increment observable change count diff --git a/v3/src/components/data-display/models/data-display-content-model.ts b/v3/src/components/data-display/models/data-display-content-model.ts index 8be85a8a2b..476c45eae1 100644 --- a/v3/src/components/data-display/models/data-display-content-model.ts +++ b/v3/src/components/data-display/models/data-display-content-model.ts @@ -19,7 +19,7 @@ import { IDataConfigurationModel } from "./data-configuration-model" import {defaultBackgroundColor} from "../../../utilities/color-utils" import { MarqueeMode, PointDisplayTypes } from "../data-display-types" import { IGetTipTextProps } from "../data-tip-types" -import { IAxisModel, isNumericAxisModel } from "../../axis/models/axis-model" +import { IAxisModel, isBaseNumericAxisModel } from "../../axis/models/axis-model" export const DataDisplayContentModel = TileContentModel .named("DataDisplayContentModel") @@ -41,7 +41,7 @@ export const DataDisplayContentModel = TileContentModel return false }, hasDraggableNumericAxis(axisModel: IAxisModel): boolean { - return isNumericAxisModel(axisModel) && self.pointDisplayType !== "bins" + return isBaseNumericAxisModel(axisModel) && self.pointDisplayType !== "bins" }, nonDraggableAxisTicks(formatter: (value: number) => string): { tickValues: number[], tickLabels: string[] } { // derived models should override diff --git a/v3/src/components/graph/adornments/adornment-component-info.ts b/v3/src/components/graph/adornments/adornment-component-info.ts index 4ff11a799e..5c3a78d168 100644 --- a/v3/src/components/graph/adornments/adornment-component-info.ts +++ b/v3/src/components/graph/adornments/adornment-component-info.ts @@ -1,6 +1,6 @@ import React from "react" import { AdornmentModel, IAdornmentModel } from "./adornment-models" -import { INumericAxisModel } from "../../axis/models/axis-model" +import { IBaseNumericAxisModel } from "../../axis/models/axis-model" export interface IAdornmentComponentProps { cellKey: Record @@ -9,8 +9,8 @@ export interface IAdornmentComponentProps { model: IAdornmentModel plotHeight: number plotWidth: number - xAxis?: INumericAxisModel - yAxis?: INumericAxisModel + xAxis?: IBaseNumericAxisModel + yAxis?: IBaseNumericAxisModel spannerRef?: React.RefObject } diff --git a/v3/src/components/graph/adornments/adornment-utils.ts b/v3/src/components/graph/adornments/adornment-utils.ts index f0ad4553b5..6bfc58980b 100644 --- a/v3/src/components/graph/adornments/adornment-utils.ts +++ b/v3/src/components/graph/adornments/adornment-utils.ts @@ -1,6 +1,6 @@ -import { INumericAxisModel } from "../../axis/models/axis-model" +import { IBaseNumericAxisModel } from "../../axis/models/axis-model" -export function getAxisDomains(xAxis?: INumericAxisModel, yAxis?: INumericAxisModel) { +export function getAxisDomains(xAxis?: IBaseNumericAxisModel, yAxis?: IBaseNumericAxisModel) { // establishes access to the specified axis domains for purposes of MobX observation const { domain: xDomain = [0, 1] } = xAxis || {} const { domain: yDomain = [0, 1] } = yAxis || {} diff --git a/v3/src/components/graph/adornments/lsrl/lsrl-adornment-model.ts b/v3/src/components/graph/adornments/lsrl/lsrl-adornment-model.ts index 2e117f251e..9f8667fa49 100644 --- a/v3/src/components/graph/adornments/lsrl/lsrl-adornment-model.ts +++ b/v3/src/components/graph/adornments/lsrl/lsrl-adornment-model.ts @@ -1,5 +1,6 @@ import { Instance, types } from "mobx-state-tree" import { Point } from "../../../data-display/data-display-types" +import { dataDisplayGetNumericValue } from "../../../data-display/data-display-value-utils" import { AdornmentModel, IAdornmentModel, IUpdateCategoriesOptions, PointModel } from "../adornment-models" import { leastSquaresLinearRegression, tAt0975ForDf } from "../../utilities/graph-utils" import { kLSRLType } from "./lsrl-adornment-types" @@ -79,8 +80,8 @@ export const LSRLAdornmentModel = AdornmentModel const casesInPlot = dataConfig.subPlotCases(cellKey) const caseValues: Point[] = [] casesInPlot.forEach(caseId => { - const caseValueX = dataset?.getNumeric(caseId, xAttrId) - const caseValueY = dataset?.getNumeric(caseId, yAttrId) + const caseValueX = dataDisplayGetNumericValue(dataset, caseId, xAttrId) + const caseValueY = dataDisplayGetNumericValue(dataset, caseId, yAttrId) const caseValueLegend = dataset?.getValue(caseId, legendAttrId) const isValidX = caseValueX && Number.isFinite(caseValueX) const isValidY = caseValueY && Number.isFinite(caseValueY) diff --git a/v3/src/components/graph/adornments/movable-value/movable-value-adornment-model.ts b/v3/src/components/graph/adornments/movable-value/movable-value-adornment-model.ts index d52e63a937..8e5dae1274 100644 --- a/v3/src/components/graph/adornments/movable-value/movable-value-adornment-model.ts +++ b/v3/src/components/graph/adornments/movable-value/movable-value-adornment-model.ts @@ -1,7 +1,7 @@ import { Instance, types } from "mobx-state-tree" import { AdornmentModel, IAdornmentModel, IUpdateCategoriesOptions } from "../adornment-models" import { kMovableValueType } from "./movable-value-adornment-types" -import { INumericAxisModel } from "../../../axis/models/axis-model" +import { IBaseNumericAxisModel } from "../../../axis/models/axis-model" export const MovableValueAdornmentModel = AdornmentModel .named("MovableValueAdornmentModel") @@ -116,8 +116,10 @@ export const MovableValueAdornmentModel = AdornmentModel .actions(self => ({ updateCategories(options: IUpdateCategoriesOptions) { const { xAxis, yAxis, resetPoints, dataConfig } = options - const axisMin = xAxis?.isNumeric ? (xAxis as INumericAxisModel).min : (yAxis as INumericAxisModel).min - const axisMax = xAxis?.isNumeric ? (xAxis as INumericAxisModel).max : (yAxis as INumericAxisModel).max + const axisMin = xAxis?.isNumeric ? (xAxis as IBaseNumericAxisModel).min + : (yAxis as IBaseNumericAxisModel).min + const axisMax = xAxis?.isNumeric ? (xAxis as IBaseNumericAxisModel).max + : (yAxis as IBaseNumericAxisModel).max self.setAxisMin(axisMin) self.setAxisMax(axisMax) diff --git a/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-base-component.tsx b/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-base-component.tsx index 3a30e12405..e41d0f257b 100644 --- a/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-base-component.tsx +++ b/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-base-component.tsx @@ -4,7 +4,7 @@ import { IUnivariateMeasureAdornmentModel } from "./univariate-measure-adornment import { getAxisDomains } from "../adornment-utils" import { mstAutorun } from "../../../../utilities/mst-autorun" import { useGraphDataConfigurationContext } from "../../hooks/use-graph-data-configuration-context" -import { INumericAxisModel } from "../../../axis/models/axis-model" +import { IBaseNumericAxisModel } from "../../../axis/models/axis-model" import "./univariate-measure-adornment-base-component.scss" @@ -16,8 +16,8 @@ interface IProps { model: IUnivariateMeasureAdornmentModel showLabel?: boolean valueRef: React.RefObject - xAxis?: INumericAxisModel - yAxis?: INumericAxisModel + xAxis?: IBaseNumericAxisModel + yAxis?: IBaseNumericAxisModel refreshValues: () => void setIsVertical: (isVertical: boolean) => void } diff --git a/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-model.ts b/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-model.ts index 4493e7efd8..7f111ae98d 100644 --- a/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-model.ts +++ b/v3/src/components/graph/adornments/univariate-measures/univariate-measure-adornment-model.ts @@ -4,6 +4,7 @@ import { AdornmentModel, IAdornmentModel, IUpdateCategoriesOptions, PointModel } import { IDataConfigurationModel } from "../../../data-display/models/data-configuration-model" import {IGraphDataConfigurationModel} from "../../models/graph-data-configuration-model" import { isFiniteNumber } from "../../../../utilities/math-utils" +import { dataDisplayGetNumericValue } from "../../../data-display/data-display-value-utils" export const MeasureInstance = types.model("MeasureInstance", { labelCoords: types.maybe(PointModel) @@ -43,7 +44,7 @@ export const UnivariateMeasureAdornmentModel = AdornmentModel const casesInPlot = dataConfig.subPlotCases(cellKey) const caseValues: number[] = [] casesInPlot.forEach(caseId => { - const caseValue = dataset?.getNumeric(caseId, attrId) + const caseValue = dataDisplayGetNumericValue(dataset, caseId, attrId) if (isFiniteNumber(caseValue)) { caseValues.push(caseValue) } diff --git a/v3/src/components/graph/component-handler-graph.test.ts b/v3/src/components/graph/component-handler-graph.test.ts index 67e3454b6f..ab3736e5f5 100644 --- a/v3/src/components/graph/component-handler-graph.test.ts +++ b/v3/src/components/graph/component-handler-graph.test.ts @@ -1,4 +1,5 @@ import { getSnapshot } from "mobx-state-tree" +import { IBaseNumericAxisModel } from "../axis/models/axis-model" import { V2GetGraph } from "../../data-interactive/data-interactive-component-types" import { DIComponentInfo } from "../../data-interactive/data-interactive-types" import { diComponentHandler } from "../../data-interactive/handlers/component-handler" @@ -6,7 +7,6 @@ import { setupTestDataset, testCases } from "../../data-interactive/handlers/han import { testGetComponent } from "../../data-interactive/handlers/component-handler-test-utils" import { appState } from "../../models/app-state" import { toV3Id } from "../../utilities/codap-utils" -import { INumericAxisModel } from "../axis/models/axis-model" import { kGraphIdPrefix } from "./graph-defs" import "./graph-registration" import { IGraphContentModel, isGraphContentModel } from "./models/graph-content-model" @@ -104,19 +104,19 @@ describe("DataInteractive ComponentHandler Graph", () => { const xAttributeId = dataConfiguration.attributeDescriptionForRole("x")!.attributeID expect(xAttributeName).toBe(graphDataset.getAttribute(xAttributeId)?.name) - const xAxis = content.getAxis("bottom") as INumericAxisModel + const xAxis = content.getAxis("bottom") as IBaseNumericAxisModel expect(xLowerBound).toBe(xAxis.min) expect(xUpperBound).toBe(xAxis.max) const yAttributeId = dataConfiguration.attributeDescriptionForRole("y")!.attributeID expect(yAttributeName).toBe(graphDataset.getAttribute(yAttributeId)?.name) - const yAxis = content.getAxis("left") as INumericAxisModel + const yAxis = content.getAxis("left") as IBaseNumericAxisModel expect(yLowerBound).toBe(yAxis.min) expect(yUpperBound).toBe(yAxis.max) const y2AttributeId = dataConfiguration.attributeDescriptionForRole("rightNumeric")!.attributeID expect(y2AttributeName).toBe(graphDataset.getAttribute(y2AttributeId)?.name) - const y2Axis = content.getAxis("rightNumeric") as INumericAxisModel + const y2Axis = content.getAxis("rightNumeric") as IBaseNumericAxisModel expect(y2LowerBound).toBe(y2Axis.min) expect(y2UpperBound).toBe(y2Axis.max) }) diff --git a/v3/src/components/graph/components/scatter-plot-utils.ts b/v3/src/components/graph/components/scatter-plot-utils.ts index 30c30a7dad..a030dfa99b 100644 --- a/v3/src/components/graph/components/scatter-plot-utils.ts +++ b/v3/src/components/graph/components/scatter-plot-utils.ts @@ -3,6 +3,7 @@ import { IGraphDataConfigurationModel } from "../models/graph-data-configuration import { GraphLayout } from "../models/graph-layout" import { ILineDescription, ISquareOfResidual } from "../adornments/shared-adornment-types" import { IConnectingLineDescription } from "../../data-display/data-display-types" +import { dataDisplayGetNumericValue } from "../../data-display/data-display-value-utils" export function scatterPlotFuncs(layout: GraphLayout, dataConfiguration?: IGraphDataConfigurationModel) { const { dataset: data, yAttributeIDs: yAttrIDs = [], hasY2Attribute, numberOfPlots = 1 } = dataConfiguration || {} @@ -18,7 +19,7 @@ export function scatterPlotFuncs(layout: GraphLayout, dataConfiguration?: IGraph const topScale = layout.getAxisScale('top') as ScaleBand | undefined function getXCoord(caseID: string) { - const xValue = data?.getNumeric(caseID, xAttrID) ?? NaN + const xValue = dataDisplayGetNumericValue(data, caseID, xAttrID) ?? NaN const topValue = data?.getStrValue(caseID, topSplitID) ?? '' const topCoord = (topValue && topScale?.(topValue)) || 0 return xScale(xValue) / numExtraPrimaryBands + topCoord @@ -26,7 +27,7 @@ export function scatterPlotFuncs(layout: GraphLayout, dataConfiguration?: IGraph function getYCoord(caseID: string, plotNum = 0) { const yAttrID = yAttrIDs[plotNum] - const yValue = data?.getNumeric(caseID, yAttrID) ?? NaN + const yValue = dataDisplayGetNumericValue(data, caseID, yAttrID) ?? NaN const yScale = y2Scale && plotNum === numberOfPlots - 1 ? y2Scale : y1Scale const rightValue = data?.getStrValue(caseID, rightSplitID) ?? '' const rightCoord = ((rightValue && rightScale?.(rightValue)) || 0) @@ -34,9 +35,9 @@ export function scatterPlotFuncs(layout: GraphLayout, dataConfiguration?: IGraph } function getCaseCoords(caseID: string, plotNum = 0) { - const xValue = data?.getNumeric(caseID, xAttrID) ?? NaN + const xValue = dataDisplayGetNumericValue(data, caseID, xAttrID) ?? NaN const yAttrID = yAttrIDs[plotNum] - const yValue = data?.getNumeric(caseID, yAttrID) ?? NaN + const yValue = dataDisplayGetNumericValue(data, caseID, yAttrID) ?? NaN const rightValue = data?.getStrValue(caseID, rightSplitID) ?? "" const rightCoord = (rightValue && rightScale?.(rightValue)) || 0 const xCoord = getXCoord(caseID) diff --git a/v3/src/components/graph/components/scatterdots.tsx b/v3/src/components/graph/components/scatterdots.tsx index d5d41a3f2b..3ee6b5ca80 100644 --- a/v3/src/components/graph/components/scatterdots.tsx +++ b/v3/src/components/graph/components/scatterdots.tsx @@ -8,7 +8,8 @@ import { firstVisibleParentAttribute, idOfChildmostCollectionForAttributes } fro import {ScaleNumericBaseType} from "../../axis/axis-types" import {CaseData} from "../../data-display/d3-types" import {PlotProps} from "../graphing-types" -import {handleClickOnCase, setPointSelection} from "../../data-display/data-display-utils" +import { handleClickOnCase, setPointSelection } from "../../data-display/data-display-utils" +import { dataDisplayGetNumericValue } from "../../data-display/data-display-value-utils" import {useDataDisplayAnimation} from "../../data-display/hooks/use-data-display-animation" import {getScreenCoord, setPointCoordinates} from "../utilities/graph-utils" import {useGraphContentModelContext} from "../hooks/use-graph-content-model-context" @@ -97,8 +98,8 @@ export const ScatterDots = observer(function ScatterDots(props: PlotProps) { selection?.forEach(anID => { selectedDataObjects.current[anID] = { - x: dataset?.getNumeric(anID, xAttrID) ?? 0, - y: dataset?.getNumeric(anID, secondaryAttrIDsRef.current[plotNumRef.current]) ?? 0 + x: dataDisplayGetNumericValue(dataset, anID, xAttrID) ?? 0, + y: dataDisplayGetNumericValue(dataset, anID, secondaryAttrIDsRef.current[plotNumRef.current]) ?? 0 } }) }, [dataConfiguration, dataset, stopAnimation]) @@ -118,8 +119,8 @@ export const ScatterDots = observer(function ScatterDots(props: PlotProps) { caseValues: ICase[] = [], { selection } = dataConfiguration || {} selection?.forEach((anID: string) => { - const currX = Number(dataset?.getNumeric(anID, xAttrID)), - currY = Number(dataset?.getNumeric(anID, secondaryAttrIDsRef.current[plotNumRef.current])) + const currX = Number(dataDisplayGetNumericValue(dataset, anID, xAttrID)), + currY = Number(dataDisplayGetNumericValue(dataset, anID, secondaryAttrIDsRef.current[plotNumRef.current])) if (isFinite(currX) && isFinite(currY)) { caseValues.push({ __id__: anID, diff --git a/v3/src/components/graph/graphing-types.ts b/v3/src/components/graph/graphing-types.ts index 87af6fab04..56aa071364 100644 --- a/v3/src/components/graph/graphing-types.ts +++ b/v3/src/components/graph/graphing-types.ts @@ -39,7 +39,8 @@ export const PlotTypes = ["casePlot", "dotPlot", "dotChart", "scatterPlot"] as c export type PlotType = typeof PlotTypes[number] export const kAxisTickLength = 4, - kAxisGap = 2 + kAxisGap = 2, + kDefaultFontHeight = 12 export const kGraphClass = "graph-plot" export const kGraphClassSelector = `.${kGraphClass}` diff --git a/v3/src/components/graph/hooks/use-dot-plot-drag-drop.ts b/v3/src/components/graph/hooks/use-dot-plot-drag-drop.ts index e9996def76..9c27584c38 100644 --- a/v3/src/components/graph/hooks/use-dot-plot-drag-drop.ts +++ b/v3/src/components/graph/hooks/use-dot-plot-drag-drop.ts @@ -7,6 +7,7 @@ import { ScaleLinear } from "d3" import { IPixiPointMetadata } from "../../data-display/pixi/pixi-points" import { appState } from "../../../models/app-state" import { handleClickOnCase } from "../../data-display/data-display-utils" +import { dataDisplayGetNumericValue } from "../../data-display/data-display-value-utils" import { useRef, useState } from "react" import { ICase } from "../../../models/data/data-set-types" @@ -42,7 +43,7 @@ export const useDotPlotDragDrop = () => { // Record the current values, so we can change them during the drag and restore them when done const {selection} = dataConfig || {} selection?.forEach((anID: string) => { - const itsValue = dataset?.getNumeric(anID, primaryAttrID) || undefined + const itsValue = dataDisplayGetNumericValue(dataset, anID, primaryAttrID) || undefined if (itsValue != null) { selectedDataObjects.current[anID] = itsValue } @@ -60,7 +61,7 @@ export const useDotPlotDragDrop = () => { const caseValues: ICase[] = [] const {selection} = dataConfig || {} selection?.forEach(anID => { - const currValue = Number(dataset?.getNumeric(anID, primaryAttrID)) + const currValue = Number(dataDisplayGetNumericValue(dataset, anID, primaryAttrID)) if (isFinite(currValue)) { caseValues.push({__id__: anID, [primaryAttrID]: currValue + delta}) } diff --git a/v3/src/components/graph/hooks/use-dot-plot.ts b/v3/src/components/graph/hooks/use-dot-plot.ts index e5c09176bc..632be6d0aa 100644 --- a/v3/src/components/graph/hooks/use-dot-plot.ts +++ b/v3/src/components/graph/hooks/use-dot-plot.ts @@ -10,6 +10,7 @@ import { useGraphLayoutContext } from "./use-graph-layout-context" import { AxisPlace } from "../../axis/axis-types" import { GraphAttrRole } from "../../data-display/data-display-types" import { setPointSelection } from "../../data-display/data-display-utils" +import { dataDisplayGetNumericValue } from "../../data-display/data-display-value-utils" import { useDataDisplayAnimation } from "../../data-display/hooks/use-data-display-animation" import { SubPlotCells } from "../models/sub-plot-cells" import { PixiPoints } from "../../data-display/pixi/pixi-points" @@ -86,7 +87,7 @@ export const useDotPlot = (pixiPoints?: PixiPoints) => { let primaryScreenCoord = primaryCoord + extraPrimaryCoord if (graphModel.pointDisplayType !== "histogram") { - const caseValue = dataset?.getNumeric(anID, primaryAttrID) ?? -1 + const caseValue = dataDisplayGetNumericValue(dataset, anID, primaryAttrID) ?? -1 const binForCase = determineBinForCase(caseValue, binWidth, minBinEdge) primaryScreenCoord = adjustCoordForStacks({ anID, axisType: "primary", binForCase, binMap, bins, pointDiameter, secondaryBandwidth, @@ -112,7 +113,7 @@ export const useDotPlot = (pixiPoints?: PixiPoints) => { if (graphModel.pointDisplayType !== "histogram") { const onePixelOffset = primaryIsBottom ? -1 : 1 - const casePrimaryValue = dataset?.getNumeric(anID, primaryAttrID) ?? -1 + const casePrimaryValue = dataDisplayGetNumericValue(dataset, anID, primaryAttrID) ?? -1 const binForCase = determineBinForCase(casePrimaryValue, binWidth, minBinEdge) secondaryScreenCoord = adjustCoordForStacks({ anID, axisType: "secondary", binForCase, binMap, bins, pointDiameter, secondaryBandwidth, diff --git a/v3/src/components/graph/hooks/use-graph-model.ts b/v3/src/components/graph/hooks/use-graph-model.ts index 20f42d8220..21c02c9aad 100644 --- a/v3/src/components/graph/hooks/use-graph-model.ts +++ b/v3/src/components/graph/hooks/use-graph-model.ts @@ -3,11 +3,12 @@ import {useCallback, useEffect} from "react" import {mstReaction} from "../../../utilities/mst-reaction" import {onAnyAction} from "../../../utilities/mst-utils" import {useDataSetContext} from "../../../hooks/use-data-set-context" -import {matchCirclesToData} from "../../data-display/data-display-utils" +import { matchCirclesToData } from "../../data-display/data-display-utils" +import { dataDisplayGetNumericValue } from "../../data-display/data-display-value-utils" import {setNiceDomain} from "../utilities/graph-utils" import {PixiPoints} from "../../data-display/pixi/pixi-points" import {IGraphContentModel} from "../models/graph-content-model" -import {INumericAxisModel} from "../../axis/models/axis-model" +import {IBaseNumericAxisModel} from "../../axis/models/axis-model" interface IProps { graphModel: IGraphContentModel @@ -49,8 +50,9 @@ export function useGraphModel(props: IProps) { startAnimation() // In case the y-values have changed we rescale if (newPlotType === 'scatterPlot') { - const values = caseDataArray?.map(({ caseID }) => dataset?.getNumeric(caseID, yAttrID)) as number[] - setNiceDomain(values || [], yAxisModel as INumericAxisModel) + const values = caseDataArray?.map(({ caseID }) => + dataDisplayGetNumericValue(dataset, caseID, yAttrID)) as number[] + setNiceDomain(values || [], yAxisModel as IBaseNumericAxisModel) } } }) diff --git a/v3/src/components/graph/models/graph-content-model.ts b/v3/src/components/graph/models/graph-content-model.ts index 3a66c7f4df..aa1e2f5bc2 100644 --- a/v3/src/components/graph/models/graph-content-model.ts +++ b/v3/src/components/graph/models/graph-content-model.ts @@ -16,7 +16,8 @@ import {IDataSet} from "../../../models/data/data-set" import { getDataSetFromId, getSharedCaseMetadataFromDataset, getTileCaseMetadata, getTileDataSet } from "../../../models/shared/shared-data-utils" -import {computePointRadius} from "../../data-display/data-display-utils" +import { computePointRadius } from "../../data-display/data-display-utils" +import { dataDisplayGetNumericValue } from "../../data-display/data-display-value-utils" import {IGraphDataConfigurationModel} from "./graph-data-configuration-model" import {DataDisplayContentModel} from "../../data-display/models/data-display-content-model" import {GraphPlace} from "../../axis-graph-shared" @@ -29,8 +30,10 @@ import { CatMapType, CellType, IDomainOptions, PlotType, PlotTypes } from "../gr import {setNiceDomain} from "../utilities/graph-utils" import {GraphPointLayerModel, IGraphPointLayerModel, kGraphPointLayerType} from "./graph-point-layer-model" import {IAdornmentModel, IUpdateCategoriesOptions} from "../adornments/adornment-models" -import {AxisModelUnion, EmptyAxisModel, IAxisModelUnion, isNumericAxisModel, - NumericAxisModel} from "../../axis/models/axis-model" +import { + AxisModelUnion, EmptyAxisModel, IAxisModelUnion, isBaseNumericAxisModel, isNumericAxisModel, + NumericAxisModel +} from "../../axis/models/axis-model" import {AdornmentsStore} from "../adornments/adornments-store" import {getPlottedValueFormulaAdapter} from "../../../models/formula/plotted-value-formula-adapter" import {getPlottedFunctionFormulaAdapter} from "../../../models/formula/plotted-function-formula-adapter" @@ -149,7 +152,8 @@ export const GraphContentModel = DataDisplayContentModel }, getNumericAxis(place: AxisPlace) { const axis = self.axes.get(place) - return isNumericAxisModel(axis) ? axis : undefined + // Include DataAxisModels + return isBaseNumericAxisModel(axis) ? axis : undefined }, getAttributeID(place: GraphAttrRole) { return self.dataConfiguration.attributeID(place) ?? '' @@ -203,10 +207,10 @@ export const GraphContentModel = DataDisplayContentModel const { initialize = false } = options ?? {} const { caseDataArray, dataset, primaryAttributeID } = self.dataConfiguration const minValue = caseDataArray.reduce((min, aCaseData) => { - return Math.min(min, dataset?.getNumeric(aCaseData.caseID, primaryAttributeID) ?? min) + return Math.min(min, dataDisplayGetNumericValue(dataset, aCaseData.caseID, primaryAttributeID) ?? min) }, Infinity) const maxValue = caseDataArray.reduce((max, aCaseData) => { - return Math.max(max, dataset?.getNumeric(aCaseData.caseID, primaryAttributeID) ?? max) + return Math.max(max, dataDisplayGetNumericValue(dataset, aCaseData.caseID, primaryAttributeID) ?? max) }, -Infinity) if (minValue === Infinity || maxValue === -Infinity) { @@ -354,7 +358,7 @@ export const GraphContentModel = DataDisplayContentModel const { binWidth, minBinEdge } = self.binDetails() const binIndex = Math.floor((Number(value) - minBinEdge) / binWidth) matchingCases = allCases?.filter(aCase => { - const caseValue = dataset?.getNumeric(aCase.__id__, attrID) ?? 0 + const caseValue = dataDisplayGetNumericValue(dataset, aCase.__id__, attrID) ?? 0 const bin = Math.floor((caseValue - minBinEdge) / binWidth) return bin === binIndex }) as ICase[] ?? [] @@ -552,7 +556,7 @@ export const GraphContentModel = DataDisplayContentModel AxisPlaces.forEach((axisPlace: AxisPlace) => { const axis = self.getAxis(axisPlace), role = axisPlaceToAttrRole[axisPlace] - if (isNumericAxisModel(axis)) { + if (isBaseNumericAxisModel(axis)) { const numericValues = dataConfiguration.numericValuesForAttrRole(role) setNiceDomain(numericValues, axis, self.axisDomainOptions) } @@ -664,7 +668,7 @@ export const GraphContentModel = DataDisplayContentModel get noPossibleRescales() { return self.plotType !== 'casePlot' && !AxisPlaces.find((axisPlace: AxisPlace) => { - return isNumericAxisModel(self.getAxis(axisPlace)) + return isBaseNumericAxisModel(self.getAxis(axisPlace)) }) }, getTipText(props: IGetTipTextProps) { diff --git a/v3/src/components/graph/models/graph-model-utils.ts b/v3/src/components/graph/models/graph-model-utils.ts index b1deab6689..658c429ac3 100644 --- a/v3/src/components/graph/models/graph-model-utils.ts +++ b/v3/src/components/graph/models/graph-model-utils.ts @@ -1,5 +1,6 @@ +import { stringValuesToDateSeconds } from "../../../utilities/date-utils" import { - CategoricalAxisModel, EmptyAxisModel, isNumericAxisModel, NumericAxisModel + CategoricalAxisModel, DateAxisModel, EmptyAxisModel, isDateAxisModel, isNumericAxisModel, NumericAxisModel } from "../../axis/models/axis-model" import { AxisPlace, AxisPlaces } from "../../axis/axis-types" import { axisPlaceToAttrRole, graphPlaceToAttrRole } from "../../data-display/data-display-types" @@ -31,9 +32,11 @@ function setPrimaryRoleAndPlotType(graphModel: IGraphContentModel) { : attributeType !== 'empty' ? oldPrimaryRole : otherAttrRole dataConfig?.setPrimaryRole(primaryRole) // TODO COLOR: treat color like categorical for now - const primaryType = attributeType === 'color' ? 'categorical' : attributeType + const typeOverrides: Record = { color: 'categorical', date: 'numeric' }, + primaryType = typeOverrides[attributeType] ?? attributeType, + secondaryType = typeOverrides[otherAttributeType] ?? otherAttributeType // This doesn't actually necessarily index by [primary][secondary], but that doesn't matter. - graphModel?.setPlotType(plotChoices[primaryType][otherAttributeType]) + graphModel?.setPlotType(plotChoices[primaryType][secondaryType]) } function setupAxes(graphModel: IGraphContentModel, layout: GraphLayout) { @@ -74,6 +77,21 @@ function setupAxes(graphModel: IGraphContentModel, layout: GraphLayout) { setCategorySet(dataConfig?.categorySetForAttrRole(attrRole)) } break + case 'date': { + if (!currAxisModel || !isDateAxisModel(currAxisModel)) { + const newAxisModel = DateAxisModel.create({place, min: 0, max: 1}) + graphModel?.setAxis(place, newAxisModel) + dataConfig?.setAttributeType(attrRole, 'date') + layout.setAxisScaleType(place, 'linear') + const valuesInSeconds = stringValuesToDateSeconds(attr?.strValues || []) + setNiceDomain(valuesInSeconds, newAxisModel, graphModel?.axisDomainOptions) + } + else { + const valuesInSeconds = stringValuesToDateSeconds(attr?.strValues || []) + setNiceDomain(valuesInSeconds, currAxisModel, graphModel?.axisDomainOptions) + } + } + break case 'empty': { if (currentType !== 'empty') { layout.setAxisScaleType(place, 'ordinal') diff --git a/v3/src/components/graph/utilities/dot-plot-utils.ts b/v3/src/components/graph/utilities/dot-plot-utils.ts index 350d4762d8..0fee066ad8 100644 --- a/v3/src/components/graph/utilities/dot-plot-utils.ts +++ b/v3/src/components/graph/utilities/dot-plot-utils.ts @@ -5,6 +5,7 @@ import { ScaleBand, ScaleLinear, max, range } from "d3" import { CaseData } from "../../data-display/d3-types" import { IDataSet } from "../../../models/data/data-set" +import { dataDisplayGetNumericValue } from "../../data-display/data-display-value-utils" import { IGraphDataConfigurationModel } from "../models/graph-data-configuration-model" import { GraphLayout } from "../models/graph-layout" import { AxisPlace } from "../../axis/axis-types" @@ -88,12 +89,12 @@ export interface IAdjustCoordForStacks { export const computePrimaryCoord = (props: IComputePrimaryCoord) => { const { anID, binWidth = 0, dataset, extraPrimaryAttrID, extraPrimaryAxisScale, isBinned = false, minBinEdge = 0, numExtraPrimaryBands, primaryAttrID, primaryAxisScale } = props - const caseValue = dataset?.getNumeric(anID, primaryAttrID) ?? NaN + const caseValue = dataDisplayGetNumericValue(dataset, anID, primaryAttrID) ?? NaN const binNumber = determineBinForCase(caseValue, binWidth, minBinEdge) ?? 0 const binMidpoint = ((minBinEdge + binNumber * binWidth) - binWidth / 2) / numExtraPrimaryBands const primaryCoord = isBinned ? primaryAxisScale(binMidpoint) - : primaryAxisScale(dataset?.getNumeric(anID, primaryAttrID) ?? NaN) / numExtraPrimaryBands + : primaryAxisScale(dataDisplayGetNumericValue(dataset, anID, primaryAttrID) ?? NaN) / numExtraPrimaryBands const extraPrimaryValue = dataset?.getStrValue(anID, extraPrimaryAttrID) const extraPrimaryCoord = extraPrimaryValue ? extraPrimaryAxisScale(extraPrimaryValue ?? "__main__") ?? 0 : 0 return { primaryCoord, extraPrimaryCoord } @@ -153,7 +154,7 @@ export const computeBinPlacements = (props: IComputeBinPlacements) => { if (primaryAxisScale) { dataConfig?.caseDataArray.forEach((aCaseData: CaseData) => { const anID = aCaseData.caseID - const caseValue = dataset?.getNumeric(anID, primaryAttrID) ?? -1 + const caseValue = dataDisplayGetNumericValue(dataset, anID, primaryAttrID) ?? -1 const numerator = primaryAxisScale(caseValue) / numExtraPrimaryBands const bin = totalNumberOfBins ? determineBinForCase(caseValue, binWidth, minBinEdge) diff --git a/v3/src/components/graph/utilities/graph-utils.ts b/v3/src/components/graph/utilities/graph-utils.ts index 6b9f46bdcd..cfe1897e66 100644 --- a/v3/src/components/graph/utilities/graph-utils.ts +++ b/v3/src/components/graph/utilities/graph-utils.ts @@ -5,7 +5,7 @@ import {IPixiPointMetadata, PixiPoints} from "../../data-display/pixi/pixi-point import {IDataSet} from "../../../models/data/data-set" import {CaseData} from "../../data-display/d3-types" import {Point, PointDisplayType, transitionDuration} from "../../data-display/data-display-types" -import {IAxisModel, isNumericAxisModel} from "../../axis/models/axis-model" +import { IAxisModel, isDateAxisModel, isNumericAxisModel } from "../../axis/models/axis-model" import {ScaleNumericBaseType} from "../../axis/axis-types" import {defaultSelectedColor, defaultSelectedStroke, defaultSelectedStrokeWidth, defaultStrokeWidth} from "../../../utilities/color-utils" @@ -63,6 +63,11 @@ export function setNiceDomain(values: number[], axisModel: IAxisModel, options?: } axisModel.setDomain(niceMin, niceMax) } + else if (isDateAxisModel(axisModel)) { + const [minDateAsSecs, maxDateAsSecs] = extent(values, d => d) as [number, number], + addend = 0.1 * Math.abs(maxDateAsSecs - minDateAsSecs) + axisModel.setDomain(minDateAsSecs - addend, maxDateAsSecs + addend) + } } // Return the two points in logical coordinates where the line with the given diff --git a/v3/src/components/slider/slider-model.ts b/v3/src/components/slider/slider-model.ts index 06bfc923f0..09249ef87c 100644 --- a/v3/src/components/slider/slider-model.ts +++ b/v3/src/components/slider/slider-model.ts @@ -1,6 +1,6 @@ import { reaction } from "mobx" import { addDisposer, Instance, SnapshotIn, types} from "mobx-state-tree" -import { INumericAxisModel, NumericAxisModel } from "../axis/models/axis-model" +import { IBaseNumericAxisModel, NumericAxisModel } from "../axis/models/axis-model" import { GlobalValue } from "../../models/global/global-value" import { applyModelChange } from "../../models/history/apply-model-change" import { ISharedModel } from "../../models/shared/shared-model" @@ -31,10 +31,10 @@ export const SliderModel = TileContentModel get value() { return self.globalValue.value }, - getAxis(): INumericAxisModel { + getAxis(): IBaseNumericAxisModel { return self.axis }, - getNumericAxis(): INumericAxisModel { + getNumericAxis(): IBaseNumericAxisModel { return self.axis }, get domain() { diff --git a/v3/src/utilities/date-utils.ts b/v3/src/utilities/date-utils.ts index fa866c02c9..7b0a876087 100644 --- a/v3/src/utilities/date-utils.ts +++ b/v3/src/utilities/date-utils.ts @@ -1,6 +1,6 @@ import { fixYear, isDateString, parseDate } from "./date-parser" import { goodTickValue, isFiniteNumber, isNumber } from "./math-utils" -import { getDefaultLanguage } from "./translation/translate" +import { getDefaultLanguage, translate } from "./translation/translate" export enum EDateTimeLevel { eSecond = 0, @@ -31,6 +31,21 @@ export const secondsConverter = { kYear: ((((1000) * 60) * 60) * 24) * 365 } +export const shortMonthNames = [ + 'DG.Formula.DateShortMonthJanuary', + 'DG.Formula.DateShortMonthFebruary', + 'DG.Formula.DateShortMonthMarch', + 'DG.Formula.DateShortMonthApril', + 'DG.Formula.DateShortMonthMay', + 'DG.Formula.DateShortMonthJune', + 'DG.Formula.DateShortMonthJuly', + 'DG.Formula.DateShortMonthAugust', + 'DG.Formula.DateShortMonthSeptember', + 'DG.Formula.DateShortMonthOctober', + 'DG.Formula.DateShortMonthNovember', + 'DG.Formula.DateShortMonthDecember' +].map(m => { return translate(m) }) + /** * 1. Compute the outermost date-time level that changes from the * minimum to the maximum date. @@ -204,3 +219,10 @@ export function convertToDate(date: any): Date | null { } return null } + +export function stringValuesToDateSeconds(values: string[]): number[] { + return values.map(value => { + const date = parseDate(value, true) + return date ? date.getTime() / 1000 : NaN + }).filter(isFiniteNumber) +}