diff --git a/README.md b/README.md index 9a67308a11..b5bf384553 100644 --- a/README.md +++ b/README.md @@ -2417,7 +2417,7 @@ The following named markers are supported: * *circle*, equivalent to *circle-fill* - a filled circle with a white stroke and 3px radius * *circle-stroke* - a hollow circle with a colored stroke and a white fill and 3px radius -If *marker* is true, it defaults to *circle*. If *marker* is a function, it will be called with a given *color* and must return an SVG marker element. +If *marker* is true, it defaults to *circle*. If *marker* is a function, it will be called with a given *color* and *document* and must return an SVG marker element. The primary color of a marker is inherited from the *stroke* of the associated mark. The *arrow* marker is [automatically oriented](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/orient) such that it points in the tangential direction of the path at the position the marker is placed. The *circle* markers are centered around the given vertex. Note that lines whose curve is not *linear* (the default), markers are not necessarily drawn at the data positions given by *x* and *y*; marker placement is determined by the (possibly Bézier) path segments generated by the curve. To ensure that symbols are drawn at a given *x* and *y* position, consider using a [dot](#dot). diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000000..08c22e28be --- /dev/null +++ b/src/api.ts @@ -0,0 +1,890 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type {Data, DataArray, Datum, Series, Value, ValueArray} from "./data.js"; +import type {Accessor, GetColumn, pXX, ColorAccessor} from "./options.js"; +/** + * Plot API + * @link https://github.com/observablehq/plot/blob/main/README.md + */ + +/** + * API Types + */ +type pixels = number; + +/** + * The data is then arrayified, and a range of indices is computed, serving as pointers + * into a the column representation of Plot.valueof + */ + +/** + * Layout options (in pixels) + */ +type LayoutOptions = { + marginTop?: pixels; // the top margin + marginRight?: pixels; // the right margin + marginBottom?: pixels; // the bottom margin + marginLeft?: pixels; // the left margin + margin?: pixels; // shorthand for the four margins + width?: pixels; // the outer width of the plot (including margins) + height?: pixels; // the outer height of the plot (including margins) +}; + +/** + * Facet options + */ +type FacetOptions = { + facet?: { + data: Data; + x?: Accessor; + y?: Accessor; + marginTop?: pixels; // the top margin + marginRight?: pixels; // the right margin + marginBottom?: pixels; // the bottom margin + marginLeft?: pixels; // the left margin + margin?: pixels; // shorthand for the four margins + grid?: boolean; // if true, draw grid lines for each facet + label?: null; // if null, disable default facet axis labels + }; +}; + +/** + * Style options + */ +type PlotStyleOptions = { + style?: string | Partial; +}; + +/** + * Scale options + */ +type ScaleOptions = any; // TODO +type ScalesOptions = { + x?: ScaleOptions; + y?: ScaleOptions; + r?: ScaleOptions; + color?: ScaleOptions; + opacity?: ScaleOptions; + length?: ScaleOptions; + symbol?: ScaleOptions; + fx?: ScaleOptions; + fy?: ScaleOptions; + inset?: pixels; + grid?: boolean; // shorthand for x.grid and y.grid +}; + +/** + * An instantiated mark + */ +export type InstantiatedMark = { + initialize: (data: Data) => void; + z?: Accessor; // copy the user option for error messages + clip?: "frame"; + dx: number; + dy: number; + marker?: MaybeMarkerFunction; + markerStart?: MaybeMarkerFunction; + markerMid?: MaybeMarkerFunction; + markerEnd?: MaybeMarkerFunction; + + // common styles, as constants + stroke?: string | null; + fill?: string | null; + fillOpacity?: number | null; + strokeWidth?: number | null; + strokeOpacity?: number | null; + strokeLinejoin?: string | null; + strokeLinecap?: string | null; + strokeMiterlimit?: number | null; + strokeDasharray?: string | null; + strokeDashoffset?: string | null; + target?: string | null; + ariaLabel?: string | null; + ariaDescription?: string | null; + ariaHidden?: "true" | "false"; + opacity?: number | null; + mixBlendMode?: string | null; + paintOrder?: string | null; + pointerEvents?: string | null; + shapeRendering?: string | null; + + // other styles, some of which are not supported by all marks + frameAnchor?: string; + + // other properties + sort?: SortOption | null; +}; + +/** + * A mark + */ +type Mark = InstantiatedMark | (() => SVGElement | null | undefined) | Mark[]; +type MarksOptions = {marks?: Mark[]}; + +/** + * Other top-level options + */ +type TopLevelOptions = { + ariaLabel?: string | null; + ariaDescription?: string | null; + caption?: string | HTMLElement; // a caption + className?: string; + document?: Document; // the document used to create plot elements +}; + +/** + * Plot options + */ +export type PlotOptions = LayoutOptions & + FacetOptions & + MarksOptions & + PlotStyleOptions & + ScalesOptions & + TopLevelOptions; + +/** + * Legend options + */ +type LegendOptions = any; // TODO + +/** + * Plot returns a SVG or a FIGURE element, with additional properties + */ +export interface Chart extends HTMLElement { + scale: ( + scaleName: "x" | "y" | "r" | "color" | "opacity" | "length" | "symbol" | "fx" | "fy" + ) => ScaleOptions | undefined; + legend: (scaleName: "color" | "opacity" | "symbol", legendOptions: LegendOptions) => HTMLElement | undefined; +} + +export type MaybeSymbol = Accessor | SymbolName | SymbolObject | null | undefined; + +/** + * Mark channel options + */ +export type CommonChannelOptions = { + x?: Accessor | number; // TODO: OptionsX + x1?: Accessor | number; + x2?: Accessor | number; + y?: Accessor | number; // TODO: OptionsY + y1?: Accessor | number; + y2?: Accessor | number; + z?: Accessor; + fill?: ColorAccessor | null; + fillOpacity?: Accessor | number | null; + r?: Accessor; // TODO: OptionsR + stroke?: ColorAccessor | null; + strokeOpacity?: Accessor | number | null; + strokeWidth?: Accessor | number | null; + symbol?: MaybeSymbol; + opacity?: Accessor | number | null; +}; + +/** + * Mark constant style options + */ +export type ConstantStyleOptions = { + ariaDescription?: string; + ariaHidden?: boolean; + target?: string; + strokeLinecap?: string; + strokeLinejoin?: string; + strokeMiterlimit?: pixels; + strokeDasharray?: pixels | string | pixels[]; + strokeDashoffset?: string; + mixBlendMode?: string; + paintOrder?: string; + pointerEvents?: string; + shapeRendering?: string; +}; + +/** + * Default options for marks, strings or numbers + */ +export type DefaultOptions = { + ariaLabel?: string; + fill?: string; + fillOpacity?: number; + stroke?: string; + strokeOpacity?: number; + strokeWidth?: pixels; + strokeLinecap?: string; + strokeLinejoin?: string; + strokeMiterlimit?: pixels; + paintOrder?: string; +}; + +/** + * Channel styles + */ +export type ChannelStyles = { + ariaLabel?: ValueArray; + title?: ValueArray; + fill?: ValueArray; + fillOpacity?: ValueArray; + stroke?: ValueArray; + strokeOpacity?: ValueArray; + strokeWidth?: ValueArray; + opacity?: ValueArray; + href: ValueArray; + z?: ValueArray; +}; + +/** + * Inset options for marks + */ +type InsetOptions = { + inset?: pixels; + insetLeft?: pixels; + insetRight?: pixels; + insetTop?: pixels; + insetBottom?: pixels; +}; + +/** + * Interval options + * +If the interval option is specified, the binX transform is implicitly applied to the specified options. The reducer of the output y channel may be specified via the reduce option, which defaults to first. To default to zero instead of showing gaps in data, as when the observed value represents a quantity, use the sum reducer. + +Plot.lineY(observations, {x: "date", y: "temperature", interval: d3.utcDay}) +The interval option is recommended to “regularize” sampled data; for example, if your data represents timestamped temperature measurements and you expect one sample per day, use d3.utcDay as the interval. + * @link TODO: unclear where to link + * TODO: accept number or Date (for e.g., d3.utcYear) + */ +export type Interval = number | IntervalObject; +export type IntervalObject = { + floor: (v: number) => number; + offset: (v: number) => number; + range: (lo: number, hi: number) => number[]; +}; + +/** + * The Plot.normalizeX and Plot.normalizeY transforms normalize series values relative to the given basis. For example, if the series values are [y₀, y₁, y₂, …] and the first basis is used, the mapped series values would be [y₀ / y₀, y₁ / y₀, y₂ / y₀, …] as in an index chart. The basis option specifies how to normalize the series values. The following basis methods are supported: + * + * * first - the first value, as in an index chart; the default + * * last - the last value + * * min - the minimum value + * * max - the maximum value + * * mean - the mean value (average) + * * median - the median value + * * pXX - the percentile value, where XX is a number in [00,99] + * * sum - the sum of values + * * extent - the minimum is mapped to zero, and the maximum to one + * * deviation - each value is transformed by subtracting the mean and then dividing by the standard deviation + * * a function to be passed an array of values, returning the desired basis + */ +export type Basis = + | "first" + | "last" + | "min" + | "max" + | "median" + | "p50" + | "p95" + | pXX + | "sum" + | "extent" + | "deviation" + | BasisFunction; +type BasisFunction = (V: ValueArray) => Value; + +/** + * Other Mark options + */ +type OtherMarkOptions = { + // the following are necessarily channels + title?: Accessor; + href?: Accessor; + ariaLabel?: Accessor; + // filter & sort + filter?: Accessor; + sort?: SortOption | null; + reverse?: boolean; + // include in facet + facet?: "auto" | "include" | "exclude" | boolean | null; + // interval + interval?: number | Interval; + // basis for the normalize transform + basis?: Basis; + // transform + transform?: TransformOption; + initializer?: InitializerOption; +}; + +/** + * Aggregation options for Series transforms (group, bin, sort…): + * * a string describing an aggregation (first, min, sum, count…) + * * a function - passed the array of values for each series + * * an object with a reduce method, an optionally a scope + * @link https://github.com/observablehq/plot/blob/main/README.md#group + */ +export type AggregationMethod = + | (( + | "first" + | "last" + | "count" + | "sum" + | "proportion" + | "proportion-facet" + | "min" + | "min-index" + | "max" + | "max-index" + | "mean" + | "median" + | "mode" + | "p25" + | "p95" + | pXX + | "deviation" + | "variance" + | AggregationFunction + ) & {reduce?: never}) // duck-typing in maybeReduce + | Aggregate; + +/** + * An object with a reduce method, and optionally a scope, for the group transform or the bin transform + * + * the reduce method is repeatedly passed three arguments: the index for each bin (an array + * of integers), the input channel’s array of values, and the extent of the bin (an object + * {x1, x2, y1, y2}); it must then return the corresponding aggregate value for the bin. + * If the reducer object’s scope is “data”, then the reduce method is first invoked for the + * full data; the return value of the reduce method is then made available as a third argument + * (making the extent the fourth argument). Similarly if the scope is “facet”, then the reduce + * method is invoked for each facet, and the resulting reduce value is made available while + * reducing the facet’s bins. (This optional scope is used by the proportion and proportion-facet + * reducers.) + * @link https://github.com/observablehq/plot/blob/main/README.md#bin + * @link https://github.com/observablehq/plot/blob/main/README.md#group + */ + +export type Aggregate = { + label?: string; + reduce: ( + I: Series, + X: ValueArray, + contextOrExtent?: Value | BinExtent | null, + extent?: BinExtent + ) => Value | null | undefined; + scope?: "data" | "facet"; +}; + +/** + * The extent of a bin, for extent-based reducers + */ +export type BinExtent = { + x1?: number | Date; + x2?: number | Date; + y1?: number | Date; + y2?: number | Date; +}; + +type AggregationFunction = (values?: ValueArray, extent?: BinExtent) => Value; + +/** + * The sort option, inside a mark, might sort the mark's data if specified as a string or a function. + * If specified as an object, it will sort the domain of an associated scale + * @link https://github.com/observablehq/plot/blob/main/README.md#transforms + * @link https://github.com/observablehq/plot/blob/main/README.md#sort-options + */ +export type SortOption = ( + | // Field, accessor or comparator + ((string | ((d: Datum) => Datum) | Comparator) & {value?: never; channel?: never}) // duck-typing in isDomainSort + | ChannelSortOption + | DomainSortOption +) & {transform?: never}; // duck-typing in isOptions + +/** + * A comparator function returns a number to sort two values + */ +export type Comparator = (a: Datum, b: Datum) => number; + +type SortChannel = "x" | "y" | "r" | "data"; + +type ChannelSortOption = { + channel: SortChannel; + order?: "descending" | "ascending" | "none"; // TODO + value?: never; // duck-typing in isDomainSort +}; + +export type DomainSortOption = { + x?: SortChannel | SortValue; + y?: SortChannel | SortValue; + fx?: SortChannel | SortValue; + fy?: SortChannel | SortValue; + color?: SortChannel | SortValue; + reduce?: AggregationMethod; + reverse?: boolean; + limit?: number | [number, number]; + channel?: never; // duck-typing in isDomainSort + value?: never; // duck-typing in isDomainSort +}; +type SortValue = { + value: SortChannel; + reduce?: AggregationMethod; + reverse?: boolean; + limit?: number | [number, number]; +}; + +/** + * Definition for the transform and initializer functions. + */ +export type TransformFunction = ( + this: InstantiatedMark, + data: DataArray, + facets: Series[] +) => {data: DataArray; facets: Series[]; channels?: never}; + +/** + * Plot’s transforms provide a convenient mechanism for transforming data as part of a plot specification. + * @link https://github.com/observablehq/plot/blob/main/README.md#transforms + */ +export type TransformOption = TransformFunction | null | undefined; + +/** + * Initializers can be used to transform and derive new channels prior to rendering. Unlike transforms which + * operate in abstract data space, initializers can operate in screen space such as pixel coordinates and colors. + * For example, initializers can modify a marks’ positions to avoid occlusion. Initializers are invoked *after* + * the initial scales are constructed and can modify the channels or derive new channels; these in turn may (or + * may not, as desired) be passed to scales. + * @link https://github.com/observablehq/plot/blob/main/README.md#initializers + */ +export type InitializerFunction = ( + this: InstantiatedMark, + data: DataArray, + facets: Series[], + channels?: any, + scales?: any, + dimensions?: Dimensions +) => {data: DataArray; facets: Series[]; channels?: any}; +export type InitializerOption = InitializerFunction | TransformOption; // TODO + +/** + * The bin transform’s value option + * + * To control how the quantitative dimensions x and y are divided into bins, + * the following options are supported: + * * thresholds - the threshold values; see below + * * interval - an alternative method of specifying thresholds + * * domain - values outside the domain will be omitted + * * cumulative - if positive, each bin will contain all lesser bins + * + * These options may be specified either on the options or outputs object. If the domain option is not specified, it defaults to the minimum and maximum of the corresponding dimension (x or y), possibly niced to match the threshold interval to ensure that the first and last bin have the same width as other bins. If cumulative is negative (-1 by convention), each bin will contain all greater bins rather than all lesser bins, representing the complementary cumulative distribution. + * + * To pass separate binning options for x and y, the x and y input channels can be specified as an object with the options above and a value option to specify the input channel values. + * + * The thresholds option may be specified as a named method or a variety of other ways: + * + * * auto (default) - Scott’s rule, capped at 200 + * * freedman-diaconis - the Freedman–Diaconis rule + * * scott - Scott’s normal reference rule + * * sturges - Sturges’ formula + * * a count (hint) representing the desired number of bins + * * an array of n threshold values for n + 1 bins + * * an interval or time interval (for temporal binning; see below) + * * a function that returns an array, count, or time interval + * + * If the thresholds option is specified as a function, it is passed three arguments: the array of input values, the domain minimum, and the domain maximum. If a number, d3.ticks or d3.utcTicks is used to choose suitable nice thresholds. If an interval, it must expose an interval.floor(value), interval.ceil(value), interval.offset(value), and interval.range(start, stop) methods. If the interval is a time interval such as d3.utcDay, or if the thresholds are specified as an array of dates, then the binned values are implicitly coerced to dates. Time intervals are intervals that are also functions that return a Date instance when called with no arguments. + * + * If the interval option is used instead of thresholds, it may be either an interval, a time interval, or a number. If a number n, threshold values are consecutive multiples of n that span the domain; otherwise, the interval option is equivalent to the thresholds option. When the thresholds are specified as an interval, and the default domain is used, the domain will automatically be extended to start and end to align with the interval. + * + * @link https://github.com/observablehq/plot/blob/main/README.md#bin + */ +export type BinValue = { + value?: Accessor; + thresholds?: any; + interval?: number | IntervalObject; + domain?: number[] | ((V: ValueArray) => ValueArray); + cumulative?: number | boolean; + // order?: "descending" | "ascending" | Comparator; + // reverse?: boolean; +}; + +/** + * The group transform's output options + */ +export type OutputOptions = Partial<{[P in keyof CommonChannelOptions]: AggregationMethod}> & { + data?: any; // TODO: this option is not tested in any example, and not documented (https://github.com/observablehq/plot/pull/272) + title?: AggregationMethod; + href?: AggregationMethod; + filter?: AggregationMethod | null; + sort?: AggregationMethod; + reverse?: boolean; + interval?: number | Interval; +} & BinOptions; + +export type Reducer = { + name?: keyof OutputOptions; + output: GetColumn; + initialize: (data: DataArray) => void; + scope: (scope: Aggregate["scope"], I?: Series) => void; + reduce: (I: Series, extent?: BinExtent) => ValueArray; + label?: string; +}; + +/** + * The shuffle transform’s seed option + * @link https://github.com/observablehq/plot/blob/main/README.md#plotshuffleoptions + */ +export type ShuffleOptions = { + seed?: null | number; +}; + +/** + * Map methods for Plot.map, Plot.mapX, Plot.mapY + * * cumsum - a cumulative sum + * * rank - the rank of each value in the sorted array + * * quantile - the rank, normalized between 0 and 1 + * * a function to be passed an array of values, returning new values + * * an object that implements the map method + */ +export type MapMethods = "cumsum" | "rank" | "quantile" | ((S: ValueArray) => ValueArray) | MapMethod; // duck-typing in maybeMap + +export type MapMethod = {map: (I: Series, S: ValueArray, T: ValueArray) => void}; + +/** + * Selects the points of each series selected by the selector, which can be specified + * either as a function which receives as input the index of the series, the shorthand + * “first” or “last”, or as a {key: value} object with exactly one key being the name + * of a channel and the value being a function which receives as input the index of the + * series and the channel values. The value may alternatively be specified as the + * shorthand “min” and “max” which respectively select the minimum and maximum points + * for the specified channel. + * @link https://github.com/observablehq/plot/blob/main/README.md#plotselectselector-options + */ +export type Selector = ((I: Series) => Series) | "first" | "last" | Record; +export type SelectorFunction = "min" | "max" | ((I: Series, X: ValueArray) => Series); + +/** + * Stack options + * @link https://github.com/observablehq/plot/blob/main/README.md#stack + */ +type StackOptions = {offset?: Offset; order?: StackOrder; reverse?: boolean}; + +/** + * Stack order options: + * The following order methods are supported: + * + * * null - input order (default) + * * value - ascending value order (or descending with reverse) + * * sum - order series by their total value + * * appearance - order series by the position of their maximum value + * * inside-out - order the earliest-appearing series on the inside + * * a named field or function of data - order data by priority + * * an array of z values + * @link https://github.com/observablehq/plot/blob/main/README.md#stack + */ +export type StackOrder = + | null + | "value" + | "sum" + | "appearance" + | "inside-out" + | string + | ((d: Datum) => Value) + | ValueArray; + +/** + * Stack offset options + * + * After all values have been stacked from zero, an optional offset can be applied to translate or scale the stacks. The following offset methods are supported: + * + * * null - a zero baseline (default) + * * expand (or normalize) - rescale each stack to fill [0, 1] + * * center (or silhouette) - align the centers of all stacks + * * wiggle - translate stacks to minimize apparent movement + * * a function to be passed a nested index, and start, end, and z values + * + * If a given stack has zero total value, the expand offset will not adjust the stack’s position. Both the center and wiggle offsets ensure that the lowest element across stacks starts at zero for better default axes. The wiggle offset is recommended for streamgraphs, and if used, changes the default order to inside-out; see Byron & Wattenberg. + * + * If the offset is specified as a function, it will receive four arguments: an index of stacks nested by facet and then stack, an array of start values, an array of end values, and an array of z values. For stackX, the start and end values correspond to x1 and x2, while for stackY, the start and end values correspond to y1 and y2. The offset function is then responsible for mutating the arrays of start and end values, such as by subtracting a common offset for each of the indices that pertain to the same stack. + */ +type Offset = null | "expand" | "center" | "wiggle" | OffsetFunction; +export type OffsetFunction = (stacks: Series[][], Y1: Float64Array, Y2: Float64Array, Z?: ValueArray | null) => void; + +/** + * Window options + * + * The Plot.windowX and Plot.windowY transforms compute a moving window around each data point and then derive a summary statistic from values in the current window, say to compute rolling averages, rolling minimums, or rolling maximums. These transforms also take additional options: + * + * * k - the window size (the number of elements in the window) + * * anchor - how to align the window: start, middle, or end + * * reduce - the aggregation method (window reducer) + * * strict - if true, output undefined if any window value is undefined; defaults to false + * + * If the strict option is true, the output start values or end values or both (depending on the anchor) of each series may be undefined since there are not enough elements to create a window of size k; output values may also be undefined if some of the input values in the corresponding window are undefined. If the strict option is false (the default), the window will be automatically truncated as needed, and undefined input values are ignored. For example, if k is 24 and anchor is middle, then the initial 11 values have effective window sizes of 13, 14, 15, … 23, and likewise the last 12 values have effective window sizes of 23, 22, 21, … 12. Values computed with a truncated window may be noisy; if you would prefer to not show this data, set the strict option to true. + * + * The following window reducers are supported: + * + * * min - the minimum + * * max - the maximum + * * mean - the mean (average) + * * median - the median + * * mode - the mode (most common occurrence) + * * pXX - the percentile value, where XX is a number in [00,99] + * * sum - the sum of values + * * deviation - the standard deviation + * * variance - the variance per Welford’s algorithm + * * difference - the difference between the last and first window value + * * ratio - the ratio of the last and first window value + * * first - the first value + * * last - the last value + * * a function to be passed an array of k values + * + * @link https://github.com/observablehq/plot/blob/main/README.md#plotwindowk + */ +export type WindowOptions = { + k?: number; + anchor?: "start" | "middle" | "end"; + reduce?: + | "min" + | "max" + | "mean" + | "median" + | "mode" + | "p25" + | "p95" + | pXX + | "sum" + | "deviation" + | "variance" + | "difference" + | "ratio" + | "first" + | "last" + | ((values: ValueArray) => Value); + strict?: boolean; + shift?: "deprecated"; +}; + +/** + * The bin options can be specified as part of the inputs or of the outputs + */ +export type BinOptions = { + value?: Accessor; + cumulative?: boolean; + domain?: number[]; + thresholds?: number | number[]; + interval?: number | IntervalObject; +}; + +/** + * Mark options (as passed by the user or returned by a transform) + */ +export type MarkOptions = CommonChannelOptions & + ConstantStyleOptions & + InsetOptions & + BinOptions & + StackOptions & + WindowOptions & + OtherMarkOptions; +export type LineOptions = MarkOptions & MarkerOptions; +// export type RectOptions = MarkOptions & InsetOptions; // TODO: only add inset options where they are meaningful (bars, rects, etc) + +/** + * The scales passed to a mark's render function + */ +export type Scales = { + x?: Scale; + y?: Scale; + r?: Scale; + length?: Scale; + opacity?: Scale; +}; + +type Scale = any; // TODO + +/** + * The dimensions passed to a mark's render function + */ +export type Dimensions = { + width: pixels; + height: pixels; + marginLeft: pixels; + marginRight: pixels; + marginTop: pixels; + marginBottom: pixels; +}; + +/** + * A marker defines a graphic drawn on vertices of a delaunay, line or a link mark + * @link https://github.com/observablehq/plot/blob/main/README.md#markers + */ +export type MarkerOption = + | "none" + | "arrow" + | "dot" + | "circle" + | "circle-stroke" + | MarkerFunction + | boolean + | null + | undefined; +export type MarkerOptions = { + marker?: MarkerOption; + markerStart?: MarkerOption; + markerMid?: MarkerOption; + markerEnd?: MarkerOption; +}; +export type MarkerFunction = (color: string, document: Context["document"]) => SVGElement; +type MaybeMarkerFunction = MarkerFunction | null; + +/** + * Ordinal color schemes + */ +export type OrdinalSchemes = + | "accent" + | "category10" + | "dark2" + | "paired" + | "pastel1" + | "pastel2" + | "set1" + | "set2" + | "set3" + | "tableau10" + | "brbg" + | "prgn" + | "piyg" + | "puor" + | "rdbu" + | "rdgy" + | "rdylbu" + | "rdylgn" + | "spectral" + | "burd" + | "buylrd" + | "blues" + | "greens" + | "greys" + | "oranges" + | "purples" + | "reds" + | "turbo" + | "viridis" + | "magma" + | "inferno" + | "plasma" + | "cividis" + | "cubehelix" + | "warm" + | "cool" + | "bugn" + | "bupu" + | "gnbu" + | "orrd" + | "pubu" + | "pubugn" + | "purd" + | "rdpu" + | "ylgn" + | "ylgnbu" + | "ylorbr" + | "ylorrd" + | "rainbow" + | "sinebow"; + +/** + * Quantitative color schemes + */ +export type QuantitativeSchemes = DivergingSchemes | SequentialSchemes | CyclicalSchemes; + +/** + * Diverging color schemes + */ +type DivergingSchemes = + | "brbg" + | "prgn" + | "piyg" + | "puor" + | "rdbu" + | "rdgy" + | "rdylbu" + | "rdylgn" + | "spectral" + | "burd" + | "buylrd"; + +/** + * Sequential color schemes + */ +type SequentialSchemes = + | "blues" + | "greens" + | "greys" + | "purples" + | "reds" + | "oranges" + | "turbo" + | "viridis" + | "magma" + | "inferno" + | "plasma" + | "cividis" + | "cubehelix" + | "warm" + | "cool" + | "bugn" + | "bupu" + | "gnbu" + | "orrd" + | "pubugn" + | "pubu" + | "purd" + | "rdpu" + | "ylgnbu" + | "ylgn" + | "ylorbr" + | "ylorrd"; + +/** + * Cyclical color schemes + */ +type CyclicalSchemes = "rainbow" | "sinebow"; + +/** + * The context in which Plot operates + */ +export interface Context { + document: Document; +} + +/** + * Know symbols for dots + * @link https://github.com/observablehq/plot/blob/main/README.md#dot + */ +export type SymbolName = + | "asterisk" + | "circle" + | "cross" + | "diamond" + | "diamond2" + | "hexagon" + | "plus" + | "square" + | "square2" + | "star" + | "times" + | "triangle" + | "triangle2" + | "wye"; + +/** + * A symbol object with a draw function + * @link https://github.com/d3/d3-shape/blob/main/README.md#custom-symbol-types + */ +export type SymbolObject = {draw: (context: CanvasPath, size: number) => void}; + +/** + * A restrictive definition of D3 selections + */ +export type Selection = { + append: (name: string) => Selection; + attr: (name: string, value: any) => Selection; + call: (callback: (selection: Selection, ...args: any[]) => void, ...args: any[]) => Selection; + each: (callback: (d: any) => void) => Selection; + filter: (filter: (d: any, i: number) => boolean) => Selection; + property: (name: string, value: any) => Selection; + style: (name: string, value: any) => Selection; + text: (value: any) => Selection; + [Symbol.iterator]: () => IterableIterator; +}; diff --git a/src/context.js b/src/context.ts similarity index 65% rename from src/context.js rename to src/context.ts index 7603225f78..fff282d955 100644 --- a/src/context.js +++ b/src/context.ts @@ -1,9 +1,10 @@ +import type {Context} from "./api.js"; import {creator, select} from "d3"; export function Context({document = window.document} = {}) { return {document}; } -export function create(name, {document}) { +export function create(name: string, {document}: Context) { return select(creator(name).call(document.documentElement)); } diff --git a/src/curve.ts b/src/curve.ts index 9c627ee03c..5099c37dbc 100644 --- a/src/curve.ts +++ b/src/curve.ts @@ -1,3 +1,5 @@ +import type {CurveFactory, CurveBundleFactory, CurveCardinalFactory, CurveCatmullRomFactory} from "d3"; + import { curveBasis, curveBasisClosed, @@ -20,7 +22,6 @@ import { curveStepAfter, curveStepBefore } from "d3"; -import type {CurveFactory, CurveBundleFactory, CurveCardinalFactory, CurveCatmullRomFactory} from "d3"; type CurveFunction = CurveFactory | CurveBundleFactory | CurveCardinalFactory | CurveCatmullRomFactory; type CurveName = diff --git a/src/data.ts b/src/data.ts new file mode 100644 index 0000000000..0f6b30ae3c --- /dev/null +++ b/src/data.ts @@ -0,0 +1,93 @@ +/** + * Values are associated to screen encodings (positions, colors…) via scales. + */ +export type Value = number | string | Date | boolean | null | undefined; + +/** + * A Row represents a data point with values attached to field names; typically, + * a row from a tabular dataset. + */ +export type Row = Record; + +/** + * A single Datum is often a Value, a Row, or an array of values; if a Row, possible field names + * can be inferred from its keys to define accessors; if an array, typical accessors are indices, + * and length, expressed as strings + */ +export type Datum = Row | Value | Value[]; +export type FieldNames = T extends Row + ? keyof T + : T extends Value[] + ? // eslint-disable-next-line @typescript-eslint/ban-types + "length" | "0" | "1" | "2" | (string & {}) + : never; + +/** + * The marks data; typically an array of Datum, but can also + * be defined as an iterable compatible with Array.from. + */ +export type Data = ArrayLike | Iterable | TypedArray; + +/** + * An array or typed array constructor, or any class that implements Array.from + */ +export type ArrayType = ArrayConstructor | TypedArrayConstructor; + +/** + * The data is then arrayified, and a range of indices is computed, serving as pointers + * into the columnar representation of each channel + */ +export type DataArray = T[] | TypedArray; + +/** + * A series is an array of indices, used to group data into classes (e.g., groups and facets) + */ +export type index = number; // integer +export type Series = index[] | Uint32Array; +export type Facets = Series[]; + +export type NumericArray = number[] | TypedArray; +export type ValueArray = NumericArray | Value[]; + +/** + * Typed arrays are preserved through arrayify + */ +export type TypedArray = + | Int8Array + | Uint8Array + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Uint8ClampedArray + | Float32Array + | Float64Array; + +export type TypedArrayConstructor = + | Int8ArrayConstructor + | Uint8ArrayConstructor + | Int16ArrayConstructor + | Uint16ArrayConstructor + | Int32ArrayConstructor + | Uint32ArrayConstructor + | Uint8ClampedArrayConstructor + | Float32ArrayConstructor + | Float64ArrayConstructor; + +export type Constructor = T extends Int8Array + ? Int8ArrayConstructor + : T extends Uint8Array + ? Uint8ArrayConstructor + : T extends Int16Array + ? Int16ArrayConstructor + : T extends Int32Array + ? Int32ArrayConstructor + : T extends Uint32Array + ? Uint32ArrayConstructor + : T extends Uint8ClampedArray + ? Uint8ClampedArrayConstructor + : T extends Float32Array + ? Float32ArrayConstructor + : T extends Float64Array + ? Float64ArrayConstructor + : never; diff --git a/src/defined.ts b/src/defined.ts index ac1031fe32..037fed6fb5 100644 --- a/src/defined.ts +++ b/src/defined.ts @@ -1,16 +1,20 @@ -import type {Primitive} from "d3"; +import type {Value} from "./data.js"; + +// TODO: @types/d3 doesn't consider null as a Primitive +type NN = string | number | boolean | Date | undefined; + import {ascending, descending} from "d3"; -export function defined(x: Primitive | undefined): boolean { +export function defined(x: Value): boolean { return x != null && !Number.isNaN(x); } -export function ascendingDefined(a: Primitive | undefined, b: Primitive | undefined): number { - return +defined(b) - +defined(a) || ascending(a, b); +export function ascendingDefined(a: Value, b: Value): number { + return +defined(b) - +defined(a) || ascending(a as NN, b as NN); } -export function descendingDefined(a: Primitive | undefined, b: Primitive | undefined): number { - return +defined(b) - +defined(a) || descending(a, b); +export function descendingDefined(a: Value, b: Value): number { + return +defined(b) - +defined(a) || descending(a as NN, b as NN); } export function nonempty(x: unknown): boolean { diff --git a/src/format.ts b/src/format.ts index a50eb3d8e0..ecee276d6b 100644 --- a/src/format.ts +++ b/src/format.ts @@ -39,7 +39,7 @@ export function formatIsoDate(date: Date): string { return isoFormat(date, "Invalid Date"); } -export function formatAuto(locale = "en-US"): (value: any) => string | number | undefined { +export function formatAuto(locale = "en-US"): (value: any) => string | null | undefined { const number = formatNumber(locale); return (v: any) => (v instanceof Date ? formatIsoDate : typeof v === "number" ? number : string)(v); } diff --git a/src/marks/marker.js b/src/marks/marker.ts similarity index 51% rename from src/marks/marker.js rename to src/marks/marker.ts index c57a0f3642..2e2a1efad9 100644 --- a/src/marks/marker.js +++ b/src/marks/marker.ts @@ -1,12 +1,18 @@ +import type {Context, InstantiatedMark, MarkerFunction, MarkerOption, MarkerOptions, Selection} from "../api.js"; +import type {Datum, index, Series} from "../data.js"; + import {create} from "../context.js"; -export function markers(mark, {marker, markerStart = marker, markerMid = marker, markerEnd = marker} = {}) { +export function markers( + mark: InstantiatedMark, + {marker, markerStart = marker, markerMid = marker, markerEnd = marker}: MarkerOptions = {} +) { mark.markerStart = maybeMarker(markerStart); mark.markerMid = maybeMarker(markerMid); mark.markerEnd = maybeMarker(markerEnd); } -function maybeMarker(marker) { +function maybeMarker(marker: MarkerOption) { if (marker == null || marker === false) return null; if (marker === true) return markerCircleFill; if (typeof marker === "function") return marker; @@ -26,8 +32,8 @@ function maybeMarker(marker) { throw new Error(`invalid marker: ${marker}`); } -function markerArrow(color, context) { - return create("svg:marker", context) +function markerArrow(color: string, document: Context["document"]) { + return create("svg:marker", {document}) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) .attr("markerHeight", 6.67) @@ -38,22 +44,22 @@ function markerArrow(color, context) { .attr("stroke-linecap", "round") .attr("stroke-linejoin", "round") .call((marker) => marker.append("path").attr("d", "M-1.5,-3l3,3l-3,3")) - .node(); + .node() as SVGElement; } -function markerDot(color, context) { - return create("svg:marker", context) +function markerDot(color: string, document: Context["document"]) { + return create("svg:marker", {document}) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) .attr("markerHeight", 6.67) .attr("fill", color) .attr("stroke", "none") .call((marker) => marker.append("circle").attr("r", 2.5)) - .node(); + .node() as SVGElement; } -function markerCircleFill(color, context) { - return create("svg:marker", context) +function markerCircleFill(color: string, document: Context["document"]) { + return create("svg:marker", {document}) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) .attr("markerHeight", 6.67) @@ -61,11 +67,11 @@ function markerCircleFill(color, context) { .attr("stroke", "white") .attr("stroke-width", 1.5) .call((marker) => marker.append("circle").attr("r", 3)) - .node(); + .node() as SVGElement; } -function markerCircleStroke(color, context) { - return create("svg:marker", context) +function markerCircleStroke(color: string, document: Context["document"]) { + return create("svg:marker", {document}) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) .attr("markerHeight", 6.67) @@ -73,31 +79,50 @@ function markerCircleStroke(color, context) { .attr("stroke", color) .attr("stroke-width", 1.5) .call((marker) => marker.append("circle").attr("r", 3)) - .node(); + .node() as SVGElement; } let nextMarkerId = 0; -export function applyMarkers(path, mark, {stroke: S} = {}) { - return applyMarkersColor(path, mark, S && ((i) => S[i])); +export function applyMarkers( + path: Selection, + mark: InstantiatedMark, + {stroke: S}: {stroke?: string[]} = {} +) { + return applyMarkersColor(path, mark, S && ((i: index) => S[i])); } -export function applyGroupedMarkers(path, mark, {stroke: S} = {}) { - return applyMarkersColor(path, mark, S && (([i]) => S[i])); +export function applyGroupedMarkers( + path: Selection, + mark: InstantiatedMark, + {stroke: S}: {stroke?: string[]} = {} +) { + return applyMarkersColor(path, mark, S && (([i]: Series) => S[i])); } -function applyMarkersColor(path, {markerStart, markerMid, markerEnd, stroke}, strokeof = () => stroke) { - const iriByMarkerColor = new Map(); +// we're cheating typescript here by using index & Series: +// the stroke function can be applied either on an individual or on a grouped mark; +// the datum on the path will be either an index or a Series. +// The stroke is either a color channel or a (non nullish) string constant. +type StrokeAttr = (i: index & Series) => string; +type IriColorMap = Map; + +function applyMarkersColor( + path: Selection, + {markerStart, markerMid, markerEnd, stroke}: InstantiatedMark, + strokeof: StrokeAttr = () => stroke as string +) { + const iriByMarkerColor = new Map(); - function applyMarker(marker) { - return function (i) { + function applyMarker(marker: MarkerFunction) { + return function (this: SVGElement, i: index & Series) { const color = strokeof(i); let iriByColor = iriByMarkerColor.get(marker); if (!iriByColor) iriByMarkerColor.set(marker, (iriByColor = new Map())); let iri = iriByColor.get(color); if (!iri) { - const context = {document: this.ownerDocument}; - const node = this.parentNode.insertBefore(marker(color, context), this); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = this.parentNode!.insertBefore(marker(color, this.ownerDocument), this); const id = `plot-marker-${++nextMarkerId}`; node.setAttribute("id", id); iriByColor.set(color, (iri = `url(#${id})`)); diff --git a/src/options.js b/src/options.js deleted file mode 100644 index 306df57e4f..0000000000 --- a/src/options.js +++ /dev/null @@ -1,414 +0,0 @@ -import {parse as isoParse} from "isoformat"; -import {color, descending, quantile} from "d3"; - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray -const TypedArray = Object.getPrototypeOf(Uint8Array); -const objectToString = Object.prototype.toString; - -// This allows transforms to behave equivalently to channels. -export function valueof(data, value, arrayType) { - const type = typeof value; - return type === "string" - ? map(data, field(value), arrayType) - : type === "function" - ? map(data, value, arrayType) - : type === "number" || value instanceof Date || type === "boolean" - ? map(data, constant(value), arrayType) - : value && typeof value.transform === "function" - ? arrayify(value.transform(data), arrayType) - : arrayify(value, arrayType); // preserve undefined type -} - -export const field = (name) => (d) => d[name]; -export const indexOf = (d, i) => i; -export const identity = {transform: (d) => d}; -export const zero = () => 0; -export const one = () => 1; -export const yes = () => true; -export const string = (x) => (x == null ? x : `${x}`); -export const number = (x) => (x == null ? x : +x); -export const boolean = (x) => (x == null ? x : !!x); -export const first = (x) => (x ? x[0] : undefined); -export const second = (x) => (x ? x[1] : undefined); -export const constant = (x) => () => x; - -// Converts a string like “p25” into a function that takes an index I and an -// accessor function f, returning the corresponding percentile value. -export function percentile(reduce) { - const p = +`${reduce}`.slice(1) / 100; - return (I, f) => quantile(I, p, f); -} - -// Some channels may allow a string constant to be specified; to differentiate -// string constants (e.g., "red") from named fields (e.g., "date"), this -// function tests whether the given value is a CSS color string and returns a -// tuple [channel, constant] where one of the two is undefined, and the other is -// the given value. If you wish to reference a named field that is also a valid -// CSS color, use an accessor (d => d.red) instead. -export function maybeColorChannel(value, defaultValue) { - if (value === undefined) value = defaultValue; - return value === null ? [undefined, "none"] : isColor(value) ? [undefined, value] : [value, undefined]; -} - -// Similar to maybeColorChannel, this tests whether the given value is a number -// indicating a constant, and otherwise assumes that it’s a channel value. -export function maybeNumberChannel(value, defaultValue) { - if (value === undefined) value = defaultValue; - return value === null || typeof value === "number" ? [undefined, value] : [value, undefined]; -} - -// Validates the specified optional string against the allowed list of keywords. -export function maybeKeyword(input, name, allowed) { - if (input != null) return keyword(input, name, allowed); -} - -// Validates the specified required string against the allowed list of keywords. -export function keyword(input, name, allowed) { - const i = `${input}`.toLowerCase(); - if (!allowed.includes(i)) throw new Error(`invalid ${name}: ${input}`); - return i; -} - -// Promotes the specified data to an array or typed array as needed. If an array -// type is provided (e.g., Array), then the returned array will strictly be of -// the specified type; otherwise, any array or typed array may be returned. If -// the specified data is null or undefined, returns the value as-is. -export function arrayify(data, type) { - return data == null - ? data - : type === undefined - ? data instanceof Array || data instanceof TypedArray - ? data - : Array.from(data) - : data instanceof type - ? data - : type.from(data); -} - -// An optimization of type.from(values, f): if the given values are already an -// instanceof the desired array type, the faster values.map method is used. -export function map(values, f, type = Array) { - return values instanceof type ? values.map(f) : type.from(values, f); -} - -// An optimization of type.from(values): if the given values are already an -// instanceof the desired array type, the faster values.slice method is used. -export function slice(values, type = Array) { - return values instanceof type ? values.slice() : type.from(values); -} - -export function isTypedArray(values) { - return values instanceof TypedArray; -} - -// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. -export function isObject(option) { - return option?.toString === objectToString; -} - -// Disambiguates a scale options object (e.g., {color: {type: "linear"}}) from -// some other option (e.g., {color: "red"}). When creating standalone legends, -// this is used to test whether a scale is defined; this should be consistent -// with inferScaleType when there are no channels associated with the scale, and -// if this returns true, then normalizeScale must return non-null. -export function isScaleOptions(option) { - return isObject(option) && (option.type !== undefined || option.domain !== undefined); -} - -// Disambiguates an options object (e.g., {y: "x2"}) from a channel value -// definition expressed as a channel transform (e.g., {transform: …}). -export function isOptions(option) { - return isObject(option) && typeof option.transform !== "function"; -} - -// Disambiguates a sort transform (e.g., {sort: "date"}) from a channel domain -// sort definition (e.g., {sort: {y: "x"}}). -export function isDomainSort(sort) { - return isOptions(sort) && sort.value === undefined && sort.channel === undefined; -} - -// For marks specified either as [0, x] or [x1, x2], such as areas and bars. -export function maybeZero(x, x1, x2, x3 = identity) { - if (x1 === undefined && x2 === undefined) { - // {x} or {} - (x1 = 0), (x2 = x === undefined ? x3 : x); - } else if (x1 === undefined) { - // {x, x2} or {x2} - x1 = x === undefined ? 0 : x; - } else if (x2 === undefined) { - // {x, x1} or {x1} - x2 = x === undefined ? 0 : x; - } - return [x1, x2]; -} - -// For marks that have x and y channels (e.g., cell, dot, line, text). -export function maybeTuple(x, y) { - return x === undefined && y === undefined ? [first, second] : [x, y]; -} - -// A helper for extracting the z channel, if it is variable. Used by transforms -// that require series, such as moving average and normalize. -export function maybeZ({z, fill, stroke} = {}) { - if (z === undefined) [z] = maybeColorChannel(fill); - if (z === undefined) [z] = maybeColorChannel(stroke); - return z; -} - -// Returns a Uint32Array with elements [0, 1, 2, … data.length - 1]. -export function range(data) { - const n = data.length; - const r = new Uint32Array(n); - for (let i = 0; i < n; ++i) r[i] = i; - return r; -} - -// Returns a filtered range of data given the test function. -export function where(data, test) { - return range(data).filter((i) => test(data[i], i, data)); -} - -// Returns an array [values[index[0]], values[index[1]], …]. -export function take(values, index) { - return map(index, (i) => values[i]); -} - -// Based on InternMap (d3.group). -export function keyof(value) { - return value !== null && typeof value === "object" ? value.valueOf() : value; -} - -export function maybeInput(key, options) { - if (options[key] !== undefined) return options[key]; - switch (key) { - case "x1": - case "x2": - key = "x"; - break; - case "y1": - case "y2": - key = "y"; - break; - } - return options[key]; -} - -// Defines a column whose values are lazily populated by calling the returned -// setter. If the given source is labeled, the label is propagated to the -// returned column definition. -export function column(source) { - let value; - return [ - { - transform: () => value, - label: labelof(source) - }, - (v) => (value = v) - ]; -} - -// Like column, but allows the source to be null. -export function maybeColumn(source) { - return source == null ? [source] : column(source); -} - -export function labelof(value, defaultValue) { - return typeof value === "string" ? value : value && value.label !== undefined ? value.label : defaultValue; -} - -// Assuming that both x1 and x2 and lazy columns (per above), this derives a new -// a column that’s the average of the two, and which inherits the column label -// (if any). Both input columns are assumed to be quantitative. If either column -// is temporal, the returned column is also temporal. -export function mid(x1, x2) { - return { - transform(data) { - const X1 = x1.transform(data); - const X2 = x2.transform(data); - return isTemporal(X1) || isTemporal(X2) - ? map(X1, (_, i) => new Date((+X1[i] + +X2[i]) / 2)) - : map(X1, (_, i) => (+X1[i] + +X2[i]) / 2, Float64Array); - }, - label: x1.label - }; -} - -// This distinguishes between per-dimension options and a standalone value. -export function maybeValue(value) { - return value === undefined || isOptions(value) ? value : {value}; -} - -// Coerces the given channel values (if any) to numbers. This is useful when -// values will be interpolated into other code, such as an SVG transform, and -// where we don’t wish to allow unexpected behavior for weird input. -export function numberChannel(source) { - return source == null - ? null - : { - transform: (data) => valueof(data, source, Float64Array), - label: labelof(source) - }; -} - -export function isIterable(value) { - return value && typeof value[Symbol.iterator] === "function"; -} - -export function isTextual(values) { - for (const value of values) { - if (value == null) continue; - return typeof value !== "object" || value instanceof Date; - } -} - -export function isOrdinal(values) { - for (const value of values) { - if (value == null) continue; - const type = typeof value; - return type === "string" || type === "boolean"; - } -} - -export function isTemporal(values) { - for (const value of values) { - if (value == null) continue; - return value instanceof Date; - } -} - -// Are these strings that might represent dates? This is stricter than ISO 8601 -// because we want to ignore false positives on numbers; for example, the string -// "1192" is more likely to represent a number than a date even though it is -// valid ISO 8601 representing 1192-01-01. -export function isTemporalString(values) { - for (const value of values) { - if (value == null) continue; - return typeof value === "string" && isNaN(value) && isoParse(value); - } -} - -// Are these strings that might represent numbers? This is stricter than -// coercion because we want to ignore false positives on e.g. empty strings. -export function isNumericString(values) { - for (const value of values) { - if (value == null || value === "") continue; - return typeof value === "string" && !isNaN(value); - } -} - -export function isNumeric(values) { - for (const value of values) { - if (value == null) continue; - return typeof value === "number"; - } -} - -export function isFirst(values, is) { - for (const value of values) { - if (value == null) continue; - return is(value); - } -} - -// Whereas isFirst only tests the first defined value and returns undefined for -// an empty array, this tests all defined values and only returns true if all of -// them are valid colors. It also returns true for an empty array, and thus -// should generally be used in conjunction with isFirst. -export function isEvery(values, is) { - for (const value of values) { - if (value == null) continue; - if (!is(value)) return false; - } - return true; -} - -// Mostly relies on d3-color, with a few extra color keywords. Currently this -// strictly requires that the value be a string; we might want to apply string -// coercion here, though note that d3-color instances would need to support -// valueOf to work correctly with InternMap. -// https://www.w3.org/TR/SVG11/painting.html#SpecifyingPaint -export function isColor(value) { - if (typeof value !== "string") return false; - value = value.toLowerCase().trim(); - return ( - value === "none" || - value === "currentcolor" || - (value.startsWith("url(") && value.endsWith(")")) || // , e.g. pattern or gradient - (value.startsWith("var(") && value.endsWith(")")) || // CSS variable - color(value) !== null - ); -} - -export function isNoneish(value) { - return value == null || isNone(value); -} - -export function isNone(value) { - return /^\s*none\s*$/i.test(value); -} - -export function isRound(value) { - return /^\s*round\s*$/i.test(value); -} - -export function maybeFrameAnchor(value = "middle") { - return keyword(value, "frameAnchor", [ - "middle", - "top-left", - "top", - "top-right", - "right", - "bottom-right", - "bottom", - "bottom-left", - "left" - ]); -} - -// Like a sort comparator, returns a positive value if the given array of values -// is in ascending order, a negative value if the values are in descending -// order. Assumes monotonicity; only tests the first and last values. -export function order(values) { - if (values == null) return; - const first = values[0]; - const last = values[values.length - 1]; - return descending(first, last); -} - -// Unlike {...defaults, ...options}, this ensures that any undefined (but -// present) properties in options inherit the given default value. -export function inherit(options = {}, ...rest) { - let o = options; - for (const defaults of rest) { - for (const key in defaults) { - if (o[key] === undefined) { - const value = defaults[key]; - if (o === options) o = {...o, [key]: value}; - else o[key] = value; - } - } - } - return o; -} - -// Given an iterable of named things (objects with a name property), returns a -// corresponding object with properties associated with the given name. -export function Named(things) { - console.warn("named iterables are deprecated; please use an object instead"); - const names = new Set(); - return Object.fromEntries( - Array.from(things, (thing) => { - const {name} = thing; - if (name == null) throw new Error("missing name"); - const key = `${name}`; - if (key === "__proto__") throw new Error(`illegal name: ${key}`); - if (names.has(key)) throw new Error(`duplicate name: ${key}`); - names.add(key); - return [name, thing]; - }) - ); -} - -export function maybeNamed(things) { - return isIterable(things) ? Named(things) : things; -} diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000000..e12ffb3bed --- /dev/null +++ b/src/options.ts @@ -0,0 +1,571 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + ArrayType, + Constructor, + Data, + DataArray, + Datum, + FieldNames, + index, + Row, + Series, + TypedArray, + TypedArrayConstructor, + Value, + ValueArray +} from "./data.js"; +import {parse as isoParse} from "isoformat"; +import {color, descending, quantile} from "d3"; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray +const TypedArray = Object.getPrototypeOf(Uint8Array); +const objectToString = Object.prototype.toString; + +export function valueof(data: T, v: Accessor, t?: ArrayType): T; +export function valueof( + data: Data, + v: Accessor | number | Date | boolean, + type: Constructor +): U; +export function valueof(d: Data, value: V, t?: ArrayType): V[]; +export function valueof( + data: Data, + value: U, + arrayType?: ArrayConstructor +): T[U][]; +export function valueof( + data: Data, + value: AccessorFunction, + arrayType?: ArrayConstructor +): U[]; +export function valueof(data: Data, value: Accessor, arrayType?: ArrayType): ValueArray; +export function valueof( + data: Data, + value: Accessor | number | null | undefined, + arrayType?: ArrayType +): ValueArray | null | undefined; +export function valueof( + data: Data | null | undefined, + value: Accessor | number | Date | boolean | null | undefined, + arrayType: V = Array as V +): ValueArray | null | undefined { + return typeof value === "string" + ? data && map(data as Data, field(value), arrayType) // JS change: avoid crash on valueof(null, "x") + : typeof value === "function" + ? data && map(data, value, arrayType) // JS change: avoid crash on valueof(null, () => {}) + : typeof value === "number" || value instanceof Date || typeof value === "boolean" + ? data && map(data, constant(value), arrayType) // JS change: avoid crash on valueof(null, () => 1) + : value && isTransform(value) // JS change: isTransform is used to assert that value is a TransformMethod + ? arrayify(value.transform(data), arrayType) + : arrayify(value, arrayType); // preserve undefined type +} + +/** + * See Plot.valueof() + */ +export type Accessor = + | FieldNames + | AccessorFunction + | ValueArray + | TransformMethod; +export type AccessorValue> = V extends keyof T + ? T[V] + : V extends AccessorFunction + ? Val + : never; +type AccessorFunction = (d: T, i: number) => U; + +export type TransformMethod = { + transform: (data: Data | null | undefined) => ValueArray | Iterable | null | undefined; + label?: string; +}; + +function isTransform(value: ColorAccessor): value is TransformMethod { + return isObject(value) && typeof (value as TransformMethod).transform == "function"; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export type ColorAccessor = Accessor | (string & {}); + +// This function serves as default when no accessor is specified +// It assumes that the Data is already an Iterable of Values. +export const identity = {transform: (data: any): ValueArray => data}; +export const field = (name: string) => (d: any) => d && (d as Row)[name]; // JS change: avoids crash on field(name)(null) +export const indexOf = (d: Datum, i: index) => i; +export const zero = () => 0; +export const one = () => 1; +export const yes = () => true; +export const string = (x: any) => (x == null ? x : `${x}`); +export const number = (x: any) => (x == null ? x : +x); +export const boolean = (x: any) => (x == null ? x : !!x); + +export const first = (x: Value[] | [Value, any] | null | undefined) => (x ? x[0] : undefined); +export const second = (x: Value[] | null | undefined) => (x ? x[1] : undefined); +export const constant = + (x: T) => + () => + x; + +// Converts a string like “p25” into a function that takes an index I and an +// accessor function f, returning the corresponding percentile value. +export function percentile(reduce: pXX): (I: Series, f: (i: index) => any) => number | undefined { + const p = +`${reduce}`.slice(1) / 100; + return (I: Series, f: (i: index) => any) => quantile(I, p, f); +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export type pXX = `p${Digit}${Digit}` & {}; +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +// Some channels may allow a string constant to be specified; to differentiate +// string constants (e.g., "red") from named fields (e.g., "date"), this +// function tests whether the given value is a CSS color string and returns a +// tuple [channel, constant] where one of the two is undefined, and the other is +// the given value. If you wish to reference a named field that is also a valid +// CSS color, use an accessor (d => d.red) instead. +export function maybeColorChannel( + value: ColorAccessor | null | undefined, + defaultValue?: string +): [Accessor | undefined, undefined] | [undefined, string | null | undefined] { + if (value === undefined) value = defaultValue; + return value === null ? [undefined, "none"] : isColor(value) ? [undefined, value] : [value, undefined]; +} + +// Similar to maybeColorChannel, this tests whether the given value is a number +// indicating a constant, and otherwise assumes that it’s a channel value. +export function maybeNumberChannel( + value: Accessor | null | number | undefined, + defaultValue?: number +): [Accessor | null | undefined, undefined] | [undefined, number | null | undefined] { + if (value === undefined) value = defaultValue; + return value === null || typeof value === "number" ? [undefined, value] : [value, undefined]; +} + +// Validates the specified optional string against the allowed list of keywords. +export function maybeKeyword(input: null | undefined, name: string, allowed: string[]): undefined; +export function maybeKeyword(input: string, name: string, allowed: string[]): string | undefined; +export function maybeKeyword(input: string | null | undefined, name: string, allowed: string[]): string | undefined { + if (input != null) return keyword(input, name, allowed); +} + +// Validates the specified required string against the allowed list of keywords. +export function keyword(input: string | null | undefined, name: string, allowed: string[]): string { + const i = `${input}`.toLowerCase(); + if (!allowed.includes(i)) throw new Error(`invalid ${name}: ${input}`); + return i; +} + +// Promotes the specified data to an array or typed array as needed. If an array +// type is provided (e.g., Array), then the returned array will strictly be of +// the specified type; otherwise, any array or typed array may be returned. If +// the specified data is null or undefined, returns the value as-is. +export function arrayify(d: T, type?: ArrayType): T; +export function arrayify(d: T, t?: undefined): T; +export function arrayify(d: T[], t?: undefined): T[]; +export function arrayify(d: Iterable, t?: ArrayConstructor): DataArray; +export function arrayify(d: TypedArray | Iterable, type: Constructor): T; +export function arrayify( + d: Iterable | null | undefined, + t?: ArrayType +): DataArray | null | undefined; +export function arrayify(d: T[], type?: ArrayConstructor): T[]; +export function arrayify(d: T[], type: Constructor): U; +export function arrayify(d: Data, t?: ArrayType): DataArray; +export function arrayify( + data: Data | null | undefined, + type?: ArrayType +): DataArray | null | undefined { + return data == null + ? data + : type === undefined + ? data instanceof Array || data instanceof TypedArray + ? (data as any[]) + : Array.from(data as any[]) + : data instanceof type + ? data + : (type as ArrayConstructor).from(data as any[]); +} + +export type Mapper = (d: T, i: number) => U; +export type TypedMapper = (d: T, i: number) => number; + +export function map(values: Data, f: TypedMapper, type: Constructor): U; +export function map(values: any, f: any, type: Constructor): T; +export function map(values: Data, f: Mapper, type?: ArrayConstructor): U[]; +export function map( + values: Data, + f: Mapper, + type?: ArrayConstructor | TypedArrayConstructor +): U[] | TypedArray; +export function map( + values: Data, + f: Mapper, + type: V = Array as V +): TypedArray | U[] { + // An optimization of type.from(values, f): if the given values are already an + // instanceof the desired array type, the faster values.map method is used. + // Typescript doesn't handle the mixing of different typed array constructors, + // since not every mapping function is compatible, so we cast f to never to + // allow it. + return values instanceof type ? values.map(f as never) : (type as ArrayConstructor).from(values, f); +} + +// An optimization of type.from(values): if the given values are already an +// instanceof the desired array type, the faster values.slice method is used. +export function slice(values: ValueArray, type: ArrayType = Array): any[] | TypedArray { + return values instanceof type ? values.slice() : (type as ArrayConstructor).from(values); +} + +export function isTypedArray(values: ValueArray): values is TypedArray { + return values instanceof TypedArray; +} + +// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. +export function isObject(option: null | undefined): false; +export function isObject(option: any): boolean; +export function isObject(option: any): boolean { + return option?.toString === objectToString; +} + +// Disambiguates a scale options object (e.g., {color: {type: "linear"}}) from +// some other option (e.g., {color: "red"}). When creating standalone legends, +// this is used to test whether a scale is defined; this should be consistent +// with inferScaleType when there are no channels associated with the scale, and +// if this returns true, then normalizeScale must return non-null. +export function isScaleOptions(option: any): boolean { + return isObject(option) && (option.type !== undefined || option.domain !== undefined); +} + +// Disambiguates an options object (e.g., {y: "x2"}) from a channel value +// definition expressed as a channel transform (e.g., {transform: …}). +export function isOptions(option: any): option is {value?: any; channel?: any; transform: never} { + return isObject(option) && typeof (option as {transform: null}).transform !== "function"; +} + +// Disambiguates a sort transform (e.g., {sort: "date"}) from a channel domain +// sort definition (e.g., {sort: {y: "x"}}). +export function isDomainSort(sort: any): boolean { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return isObject(sort) && sort!.value === undefined && sort!.channel === undefined; +} + +// For marks specified either as [0, x] or [x1, x2], such as areas and bars. +// TODO: move this function to stack.ts? + +export function maybeZero( + x: Accessor | number | undefined, + x1: Accessor | number | undefined, + x2: Accessor | number | undefined, + x3: Accessor = identity +) { + if (x1 === undefined && x2 === undefined) { + // {x} or {} + (x1 = 0), (x2 = x === undefined ? x3 : x); + } else if (x1 === undefined) { + // {x, x2} or {x2} + x1 = x === undefined ? 0 : x; + } else if (x2 === undefined) { + // {x, x1} or {x1} + x2 = x === undefined ? 0 : x; + } + return [x1, x2]; +} + +// For marks that have x and y channels (e.g., cell, dot, line, text). +export function maybeTuple(x: T | undefined, y: T | undefined): [T | undefined, T | undefined] { + return x === undefined && y === undefined + ? [first as unknown as T | undefined, second as unknown as T | undefined] + : [x, y]; +} + +// A helper for extracting the z channel, if it is variable. Used by transforms +// that require series, such as moving average and normalize. +type ZOptions = { + fill?: ColorAccessor | null; + stroke?: ColorAccessor | null; + z?: Accessor | null; +}; +export function maybeZ({z, fill, stroke}: ZOptions = {}) { + if (z === undefined) [z] = maybeColorChannel(fill); + if (z === undefined) [z] = maybeColorChannel(stroke); + return z; +} + +// Returns a Uint32Array with elements [0, 1, 2, … data.length - 1]. +export function range(data: ArrayLike): Uint32Array { + const n = data.length; + const r = new Uint32Array(n); + for (let i = 0; i < n; ++i) r[i] = i; + return r; +} + +// Returns a filtered range of data given the test function. +export function where(data: ArrayLike, test: (d: any, i?: index, data?: ArrayLike) => boolean) { + return range(data).filter((i) => test(data[i], i, data)); +} + +// Returns an array [values[index[0]], values[index[1]], …]. +export function take(values: ValueArray, index: Series): ValueArray { + return map(index, (i: index) => values[i]); +} + +// Based on InternMap (d3.group). +export function keyof(value: Datum) { + return value !== null && typeof value === "object" ? value.valueOf() : value; +} + +// note: maybeInput doesn't type check if the field is an accessor +export function maybeInput(key: string, options: {[key: string]: any}) { + if (options[key] !== undefined) return options[key]; + switch (key) { + case "x1": + case "x2": + key = "x"; + break; + case "y1": + case "y2": + key = "y"; + break; + } + return options[key]; +} + +export function column(source: any): [GetColumn, SetColumn] { + let value: ValueArray; + return [ + { + transform: () => value, + label: labelof(source) + }, + (v) => ((value = v), v) + ]; +} +export type GetColumn = {transform: () => ValueArray; label?: string}; +type SetColumn = (v: T) => T; + +// Like column, but allows the source to be null. +export function maybeColumn(source: any): [GetColumn, SetColumn] | [null?] { + return source == null ? [source] : column(source); +} + +export function labelof(value: string): string; +export function labelof(value: null | undefined): undefined; +export function labelof(value: any & {label: string}): string; +export function labelof(value: any & {label: string}, defaultValue: string): string; +export function labelof(value: null | undefined, defaultValue?: T): T; +export function labelof(value: any, defaultValue?: string): string | undefined { + return typeof value === "string" + ? value + : value && (value as {label: string}).label != null + ? (value as {label: string}).label + : defaultValue; +} + +// Assuming that both x1 and x2 and lazy columns (per above), this derives a new +// a column that’s the average of the two, and which inherits the column label +// (if any). Both input columns are assumed to be quantitative. If either column +// is temporal, the returned column is also temporal. +export function mid(x1: GetColumn, x2: GetColumn): {transform(): ValueArray; label: string | undefined} { + return { + transform() { + const X1 = x1.transform(); + const X2 = x2.transform(); + return isTemporal(X1) || isTemporal(X2) + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + map(X1, (_, i) => new Date((+X1[i]! + +X2[i]!) / 2)) // Do we need to handle null or undefined in either of these cases? + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + map(X1, (_, i) => (+X1[i]! + +X2[i]!) / 2, Float64Array); // e.g. X1[i] == null ? X1[i] : X2[i] == null ? X2[i] : ... + }, + label: x1.label + }; +} + +// This distinguishes between per-dimension options and a standalone value. +export function maybeValue(value: any) { + return value === undefined || isOptions(value) ? value : {value}; +} + +// Coerces the given channel values (if any) to numbers. This is useful when +// values will be interpolated into other code, such as an SVG transform, and +// where we don’t wish to allow unexpected behavior for weird input. +export function numberChannel(source: Accessor | null | undefined): TransformMethod | null { + return source == null + ? null + : { + // TODO: figure out why data being Data | null | undefined doesn't + // match any of the valueof overloads. + transform: (data) => valueof(data as Data, source, Float64Array), + label: labelof(source) + }; +} + +export function isIterable(value: any): value is Iterable { + return value && typeof value[Symbol.iterator] === "function"; +} + +export function isTextual(values: Iterable): boolean { + for (const value of values) { + if (value == null) continue; + return typeof value !== "object" || value instanceof Date; + } + return false; +} + +export function isOrdinal(values: Iterable): boolean { + for (const value of values) { + if (value == null) continue; + const type = typeof value; + return type === "string" || type === "boolean"; + } + return false; +} + +export function isTemporal(values: Iterable): boolean { + for (const value of values) { + if (value == null) continue; + return value instanceof Date; + } + return false; +} + +// Are these strings that might represent dates? This is stricter than ISO 8601 +// because we want to ignore false positives on numbers; for example, the string +// "1192" is more likely to represent a number than a date even though it is +// valid ISO 8601 representing 1192-01-01. +export function isTemporalString(values: Iterable): boolean { + for (const value of values) { + if (value == null) continue; + return typeof value === "string" && isNaN(value as unknown as number) && (isoParse(value) as unknown as boolean); + } + return false; +} + +// Are these strings that might represent numbers? This is stricter than +// coercion because we want to ignore false positives on e.g. empty strings. +export function isNumericString(values: Iterable): boolean { + for (const value of values) { + if (value == null || value === "") continue; + return typeof value === "string" && !isNaN(value as unknown as number); + } + return false; +} + +export function isNumeric(values: Iterable): boolean { + for (const value of values) { + if (value == null) continue; + return typeof value === "number"; + } + return false; +} + +export function isFirst(values: IterableIterator, is: (d: Datum) => boolean) { + for (const value of values) { + if (value == null) continue; + return is(value); + } +} + +// Whereas isFirst only tests the first defined value and returns undefined for +// an empty array, this tests all defined values and only returns true if all of +// them are valid colors. It also returns true for an empty array, and thus +// should generally be used in conjunction with isFirst. +export function isEvery(values: IterableIterator, is: (d: Datum) => boolean): boolean { + for (const value of values) { + if (value == null) continue; + if (!is(value)) return false; + } + return true; +} + +// Mostly relies on d3-color, with a few extra color keywords. Currently this +// strictly requires that the value be a string; we might want to apply string +// coercion here, though note that d3-color instances would need to support +// valueOf to work correctly with InternMap. +// https://www.w3.org/TR/SVG11/painting.html#SpecifyingPaint +export function isColor(v: ColorAccessor | undefined): v is string { + if (typeof v !== "string") return false; + const value = v.toLowerCase().trim(); + return ( + value === "none" || + value === "currentcolor" || + (value.startsWith("url(") && value.endsWith(")")) || // , e.g. pattern or gradient + (value.startsWith("var(") && value.endsWith(")")) || // CSS variable + color(value) !== null + ); +} + +export function isNoneish(value: ColorAccessor | null | undefined): boolean { + return value == null || isNone(value); +} + +export function isNone(value: ColorAccessor | null | undefined): boolean { + return /^\s*none\s*$/i.test(value as string); +} + +export function isRound(value: string | undefined): boolean { + return /^\s*round\s*$/i.test(value as string); +} + +export function maybeFrameAnchor(value = "middle"): string { + return keyword(value, "frameAnchor", [ + "middle", + "top-left", + "top", + "top-right", + "right", + "bottom-right", + "bottom", + "bottom-left", + "left" + ]); +} + +// Like a sort comparator, returns a positive value if the given array of values +// is in ascending order, a negative value if the values are in descending +// order. Assumes monotonicity; only tests the first and last values. +export function order( + values: null | undefined | string[] | number[] | Float32Array | Float64Array +): number | undefined { + if (values == null) return; + const first = values[0]; + const last = values[values.length - 1]; + return descending(first, last); +} + +// Unlike {...defaults, ...options}, this ensures that any undefined (but +// present) properties in options inherit the given default value. +export function inherit(options: Record = {}, ...rest: Array>) { + let o = options; + for (const defaults of rest) { + for (const key in defaults) { + if (o[key] === undefined) { + const value = defaults[key]; + if (o === options) o = {...o, [key]: value}; + else o[key] = value; + } + } + } + return o; +} + +// Given an iterable of named things (objects with a name property), returns a +// corresponding object with properties associated with the given name. +export function Named(things: any): {[k: string]: unknown} { + console.warn("named iterables are deprecated; please use an object instead"); + const names = new Set(); + return Object.fromEntries( + Array.from(things, (thing) => { + const {name} = thing as {name: string}; + if (name == null) throw new Error("missing name"); + const key = `${name}`; + if (key === "__proto__") throw new Error(`illegal name: ${key}`); + if (names.has(key)) throw new Error(`duplicate name: ${key}`); + names.add(key); + return [name, thing]; + }) + ); +} + +export function maybeNamed(things: any): any { + return isIterable(things) ? Named(things) : things; +} diff --git a/src/plot.js b/src/plot.js index a9a27a7b20..bce790dad3 100644 --- a/src/plot.js +++ b/src/plot.js @@ -31,6 +31,8 @@ export function plot(options = {}) { const className = maybeClassName(options.className); // Flatten any nested marks. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore const marks = options.marks === undefined ? [] : options.marks.flat(Infinity).map(markify); // A Map from Mark instance to its render state, including: diff --git a/src/scales/index.js b/src/scales/index.ts similarity index 100% rename from src/scales/index.js rename to src/scales/index.ts diff --git a/src/scales/schemes.js b/src/scales/schemes.ts similarity index 79% rename from src/scales/schemes.js rename to src/scales/schemes.ts index 1b364606d3..6bc9050774 100644 --- a/src/scales/schemes.js +++ b/src/scales/schemes.ts @@ -1,3 +1,10 @@ +import type {OrdinalSchemes, QuantitativeSchemes} from "../api.js"; +import type {Value} from "../data.js"; + +type ColorInterpolator = (t: number) => string; // t is in [0, 1] +type OrdinalSchemeArray = readonly (readonly string[])[]; +type OrdinalScheme = readonly string[] | (({length}: {length: number}) => readonly string[]); // n is the number of colors + import { interpolateBlues, interpolateBrBG, @@ -77,7 +84,7 @@ import { schemeYlOrRd } from "d3"; -const ordinalSchemes = new Map([ +const ordinalSchemes = new Map([ // categorical ["accent", schemeAccent], ["category10", schemeCategory10], @@ -141,8 +148,8 @@ const ordinalSchemes = new Map([ ["sinebow", schemeicyclical(interpolateSinebow)] ]); -function scheme9(scheme, interpolate) { - return ({length: n}) => { +function scheme9(scheme: OrdinalSchemeArray, interpolate: ColorInterpolator) { + return ({length: n}: ArrayLike) => { if (n === 1) return [scheme[3][1]]; // favor midpoint if (n === 2) return [scheme[3][1], scheme[3][2]]; // favor darker n = Math.max(3, Math.floor(n)); @@ -150,37 +157,37 @@ function scheme9(scheme, interpolate) { }; } -function scheme11(scheme, interpolate) { - return ({length: n}) => { +function scheme11(scheme: OrdinalSchemeArray, interpolate: ColorInterpolator) { + return ({length: n}: {length: number}) => { if (n === 2) return [scheme[3][0], scheme[3][2]]; // favor diverging extrema n = Math.max(3, Math.floor(n)); return n > 11 ? quantize(interpolate, n) : scheme[n]; }; } -function scheme11r(scheme, interpolate) { - return ({length: n}) => { +function scheme11r(scheme: OrdinalSchemeArray, interpolate: ColorInterpolator) { + return ({length: n}: ArrayLike) => { if (n === 2) return [scheme[3][2], scheme[3][0]]; // favor diverging extrema n = Math.max(3, Math.floor(n)); return n > 11 ? quantize((t) => interpolate(1 - t), n) : scheme[n].slice().reverse(); }; } -function schemei(interpolate) { - return ({length: n}) => quantize(interpolate, Math.max(2, Math.floor(n))); +function schemei(interpolate: ColorInterpolator) { + return ({length: n}: ArrayLike) => quantize(interpolate, Math.max(2, Math.floor(n))); } -function schemeicyclical(interpolate) { - return ({length: n}) => quantize(interpolate, Math.floor(n) + 1).slice(0, -1); +function schemeicyclical(interpolate: ColorInterpolator) { + return ({length: n}: ArrayLike) => quantize(interpolate, Math.floor(n) + 1).slice(0, -1); } -export function ordinalScheme(scheme) { - const s = `${scheme}`.toLowerCase(); +export function ordinalScheme(scheme: string) { + const s = `${scheme}`.toLowerCase() as OrdinalSchemes; if (!ordinalSchemes.has(s)) throw new Error(`unknown scheme: ${s}`); - return ordinalSchemes.get(s); + return ordinalSchemes.get(s) as OrdinalScheme; // https://github.com/microsoft/TypeScript/issues/13086 } -export function ordinalRange(scheme, length) { +export function ordinalRange(scheme: OrdinalSchemes, length: number) { const s = ordinalScheme(scheme); const r = typeof s === "function" ? s({length}) : s; return r.length !== length ? r.slice(0, length) : r; @@ -189,7 +196,7 @@ export function ordinalRange(scheme, length) { // If the specified domain contains only booleans (ignoring null and undefined), // returns a corresponding range where false is mapped to the low color and true // is mapped to the high color of the specified scheme. -export function maybeBooleanRange(domain, scheme = "greys") { +export function maybeBooleanRange(domain: Iterable, scheme: OrdinalSchemes = "greys") { const range = new Set(); const [f, t] = ordinalRange(scheme, 2); for (const value of domain) { @@ -201,7 +208,7 @@ export function maybeBooleanRange(domain, scheme = "greys") { return [...range]; } -const quantitativeSchemes = new Map([ +const quantitativeSchemes = new Map([ // diverging ["brbg", interpolateBrBG], ["prgn", interpolatePRGn], @@ -253,10 +260,10 @@ const quantitativeSchemes = new Map([ ["sinebow", interpolateSinebow] ]); -export function quantitativeScheme(scheme) { - const s = `${scheme}`.toLowerCase(); +export function quantitativeScheme(scheme: string) { + const s = `${scheme}`.toLowerCase() as QuantitativeSchemes; if (!quantitativeSchemes.has(s)) throw new Error(`unknown scheme: ${s}`); - return quantitativeSchemes.get(s); + return quantitativeSchemes.get(s) as ColorInterpolator; // https://github.com/microsoft/TypeScript/issues/13086 } const divergingSchemes = new Set([ @@ -273,6 +280,6 @@ const divergingSchemes = new Set([ "buylrd" ]); -export function isDivergingScheme(scheme) { +export function isDivergingScheme(scheme: string | undefined) { return scheme != null && divergingSchemes.has(`${scheme}`.toLowerCase()); } diff --git a/src/stats.js b/src/stats.ts similarity index 81% rename from src/stats.js rename to src/stats.ts index e4fee82f05..60f49d5b40 100644 --- a/src/stats.js +++ b/src/stats.ts @@ -19,13 +19,15 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +// +// Type definitions added for Observable Plot -export function ibetainv(p, a, b) { - var EPS = 1e-8; - var a1 = a - 1; - var b1 = b - 1; - var j = 0; - var lna, lnb, pp, t, u, err, x, al, h, w, afac; +export function ibetainv(p: number, a: number, b: number) { + const EPS = 1e-8; + const a1 = a - 1; + const b1 = b - 1; + let j = 0; + let lna, lnb, pp, t, u, err, x, al, h, w; if (p <= 0) return 0; if (p >= 1) return 1; if (a >= 1 && b >= 1) { @@ -46,10 +48,10 @@ export function ibetainv(p, a, b) { if (p < t / w) x = Math.pow(a * w * p, 1 / a); else x = 1 - Math.pow(b * w * (1 - p), 1 / b); } - afac = -gammaln(a) - gammaln(b) + gammaln(a + b); + const afac = -gammaln(a) - gammaln(b) + gammaln(a + b); for (; j < 10; j++) { if (x === 0 || x === 1) return x; - err = ibeta(x, a, b) - p; + err = (ibeta(x, a, b) as number) - p; t = Math.exp(a1 * Math.log(x) + b1 * Math.log(1 - x) + afac); u = err / t; x -= t = u / (1 - 0.5 * Math.min(1, u * (a1 / x - b1 / (1 - x)))); @@ -60,9 +62,9 @@ export function ibetainv(p, a, b) { return x; } -export function ibeta(x, a, b) { +export function ibeta(x: number, a: number, b: number) { // Factors in front of the continued fraction. - var bt = + const bt = x === 0 || x === 1 ? 0 : Math.exp(gammaln(a + b) - gammaln(a) - gammaln(b) + a * Math.log(x) + b * Math.log(1 - x)); if (x < 0 || x > 1) return false; if (x < (a + 1) / (a + b + 2)) @@ -72,15 +74,15 @@ export function ibeta(x, a, b) { return 1 - (bt * betacf(1 - x, b, a)) / b; } -export function betacf(x, a, b) { - var fpmin = 1e-30; - var m = 1; - var qab = a + b; - var qap = a + 1; - var qam = a - 1; - var c = 1; - var d = 1 - (qab * x) / qap; - var m2, aa, del, h; +export function betacf(x: number, a: number, b: number) { + const fpmin = 1e-30; + let m = 1; + const qab = a + b; + const qap = a + 1; + const qam = a - 1; + let c = 1; + let d = 1 - (qab * x) / qap; + let m2, aa, del, h; // These q's will be used in factors that occur in the coefficients if (Math.abs(d) < fpmin) d = fpmin; @@ -112,22 +114,22 @@ export function betacf(x, a, b) { return h; } -export function gammaln(x) { - var j = 0; - var cof = [ +export function gammaln(x: number) { + let j = 0; + const cof = [ 76.18009172947146, -86.5053203294167, 24.01409824083091, -1.231739572450155, 0.1208650973866179e-2, -0.5395239384953e-5 ]; - var ser = 1.000000000190015; - var xx, y, tmp; + let ser = 1.000000000190015; + let xx, y, tmp; tmp = (y = xx = x) + 5.5; tmp -= (xx + 0.5) * Math.log(tmp); for (; j < 6; j++) ser += cof[j] / ++y; return Math.log((2.506628274631 * ser) / xx) - tmp; } -export function qt(p, dof) { - var x = ibetainv(2 * Math.min(p, 1 - p), 0.5 * dof, 0.5); +export function qt(p: number, dof: number) { + let x = ibetainv(2 * Math.min(p, 1 - p), 0.5 * dof, 0.5); x = Math.sqrt((dof * (1 - x)) / x); return p > 0.5 ? x : -x; } diff --git a/src/style.js b/src/style.ts similarity index 71% rename from src/style.js rename to src/style.ts index 3547c50f1e..17427f6887 100644 --- a/src/style.js +++ b/src/style.ts @@ -1,3 +1,15 @@ +import type { + ChannelStyles, + DefaultOptions, + Dimensions, + InstantiatedMark, + MarkOptions, + Scales, + Selection +} from "./api.js"; +import type {Datum, index, Series, ValueArray} from "./data.js"; +import type {Accessor} from "./options.js"; + import {group, namespaces} from "d3"; import {defined, nonempty} from "./defined.js"; import {formatDefault} from "./format.js"; @@ -8,8 +20,8 @@ export const offset = typeof window !== "undefined" && window.devicePixelRatio > let nextClipId = 0; -export function styles( - mark, +export function styles( + mark: InstantiatedMark, { title, href, @@ -32,7 +44,7 @@ export function styles( paintOrder, pointerEvents, shapeRendering - }, + }: MarkOptions, { ariaLabel: cariaLabel, fill: defaultFill = "currentColor", @@ -44,7 +56,7 @@ export function styles( strokeLinejoin: defaultStrokeLinejoin, strokeMiterlimit: defaultStrokeMiterlimit, paintOrder: defaultPaintOrder - } + }: DefaultOptions ) { // Some marks don’t support fill (e.g., tick and rule). if (defaultFill === null) { @@ -117,7 +129,7 @@ export function styles( mark.target = string(target); mark.ariaLabel = string(cariaLabel); mark.ariaDescription = string(ariaDescription); - mark.ariaHidden = string(ariaHidden); + mark.ariaHidden = string(ariaHidden) as "true" | "false" | undefined; mark.opacity = impliedNumber(copacity, 1); mark.mixBlendMode = impliedString(mixBlendMode, "normal"); mark.paintOrder = impliedString(paintOrder, "normal"); @@ -138,34 +150,34 @@ export function styles( } // Applies the specified titles via selection.call. -export function applyTitle(selection, L) { +export function applyTitle(selection: Selection, L?: ValueArray) { if (L) selection - .filter((i) => nonempty(L[i])) + .filter((i: index) => nonempty(L[i])) .append("title") .call(applyText, L); } // Like applyTitle, but for grouped data (lines, areas). -export function applyTitleGroup(selection, L) { +export function applyTitleGroup(selection: Selection, L?: ValueArray) { if (L) selection - .filter(([i]) => nonempty(L[i])) + .filter(([i]: Series) => nonempty(L[i])) .append("title") .call(applyTextGroup, L); } -export function applyText(selection, T) { - if (T) selection.text((i) => formatDefault(T[i])); +export function applyText(selection: Selection, T?: ValueArray) { + if (T) selection.text((i: index) => formatDefault(T[i])); } -export function applyTextGroup(selection, T) { - if (T) selection.text(([i]) => formatDefault(T[i])); +export function applyTextGroup(selection: Selection, T?: ValueArray) { + if (T) selection.text(([i]: Series) => formatDefault(T[i])); } export function applyChannelStyles( - selection, - {target}, + selection: Selection, + {target}: {target?: string}, { ariaLabel: AL, title: T, @@ -176,22 +188,22 @@ export function applyChannelStyles( strokeWidth: SW, opacity: O, href: H - } + }: ChannelStyles ) { - if (AL) applyAttr(selection, "aria-label", (i) => AL[i]); - if (F) applyAttr(selection, "fill", (i) => F[i]); - if (FO) applyAttr(selection, "fill-opacity", (i) => FO[i]); - if (S) applyAttr(selection, "stroke", (i) => S[i]); - if (SO) applyAttr(selection, "stroke-opacity", (i) => SO[i]); - if (SW) applyAttr(selection, "stroke-width", (i) => SW[i]); - if (O) applyAttr(selection, "opacity", (i) => O[i]); - if (H) applyHref(selection, (i) => H[i], target); + if (AL) applyAttr(selection, "aria-label", (i) => AL[i] as string); + if (F) applyAttr(selection, "fill", (i) => F[i] as string); + if (FO) applyAttr(selection, "fill-opacity", (i) => FO[i] as number); + if (S) applyAttr(selection, "stroke", (i) => S[i] as string); + if (SO) applyAttr(selection, "stroke-opacity", (i) => SO[i] as number); + if (SW) applyAttr(selection, "stroke-width", (i) => SW[i] as number); + if (O) applyAttr(selection, "opacity", (i) => O[i] as number); + if (H) applyHref(selection, (i) => H[i] as string, target); applyTitle(selection, T); } export function applyGroupedChannelStyles( - selection, - {target}, + selection: Selection, + {target}: {target?: string}, { ariaLabel: AL, title: T, @@ -202,16 +214,16 @@ export function applyGroupedChannelStyles( strokeWidth: SW, opacity: O, href: H - } + }: ChannelStyles ) { - if (AL) applyAttr(selection, "aria-label", ([i]) => AL[i]); - if (F) applyAttr(selection, "fill", ([i]) => F[i]); - if (FO) applyAttr(selection, "fill-opacity", ([i]) => FO[i]); - if (S) applyAttr(selection, "stroke", ([i]) => S[i]); - if (SO) applyAttr(selection, "stroke-opacity", ([i]) => SO[i]); - if (SW) applyAttr(selection, "stroke-width", ([i]) => SW[i]); - if (O) applyAttr(selection, "opacity", ([i]) => O[i]); - if (H) applyHref(selection, ([i]) => H[i], target); + if (AL) applyAttr(selection, "aria-label", ([i]: Series) => AL[i] as string); + if (F) applyAttr(selection, "fill", ([i]: Series) => F[i] as string); + if (FO) applyAttr(selection, "fill-opacity", ([i]: Series) => FO[i] as number); + if (S) applyAttr(selection, "stroke", ([i]: Series) => S[i] as string); + if (SO) applyAttr(selection, "stroke-opacity", ([i]: Series) => SO[i] as number); + if (SW) applyAttr(selection, "stroke-width", ([i]: Series) => SW[i] as number); + if (O) applyAttr(selection, "opacity", ([i]: Series) => O[i] as number); + if (H) applyHref(selection, ([i]: Series) => H[i] as string, target); applyTitleGroup(selection, T); } @@ -225,11 +237,11 @@ function groupAesthetics({ strokeWidth: SW, opacity: O, href: H -}) { - return [AL, T, F, FO, S, SO, SW, O, H].filter((c) => c !== undefined); +}: ChannelStyles) { + return [AL, T, F, FO, S, SO, SW, O, H].filter((c) => c !== undefined) as ValueArray[]; } -export function groupZ(I, Z, z) { +export function groupZ(I: Series, Z: ValueArray, z: Accessor) { const G = group(I, (i) => Z[i]); if (z === undefined && G.size > I.length >> 1) { warn( @@ -239,13 +251,19 @@ export function groupZ(I, Z, z) { return G.values(); } -export function* groupIndex(I, position, {z}, channels) { +export function* groupIndex( + I: Series, + position: ValueArray[], + {z}: InstantiatedMark, + channels: ChannelStyles +) { const {z: Z} = channels; // group channel const A = groupAesthetics(channels); // aesthetic channels const C = [...position, ...A]; // all channels // Group the current index by Z (if any). - for (const G of Z ? groupZ(I, Z, z) : [I]) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const G of Z ? groupZ(I, Z, z!) : [I]) { let Ag; // the A-values (aesthetics) of the current group, if any let Gg; // the current group index (a subset of G, and I), if any out: for (const i of G) { @@ -268,7 +286,7 @@ export function* groupIndex(I, position, {z}, channels) { // Otherwise, add the current index to the current group. Then, if any of // the aesthetics don’t match the current group, yield the current group // and start a new group of the current index. - Gg.push(i); + (Gg as index[]).push(i); for (let j = 0; j < A.length; ++j) { const k = keyof(A[j][i]); if (k !== Ag[j]) { @@ -287,13 +305,18 @@ export function* groupIndex(I, position, {z}, channels) { // clip: true clips to the frame // TODO: accept other types of clips (paths, urls, x, y, other marks?…) // https://github.com/observablehq/plot/issues/181 -export function maybeClip(clip) { +export function maybeClip(clip: boolean | null | undefined) { if (clip === true) return "frame"; if (clip == null || clip === false) return false; throw new Error(`invalid clip method: ${clip}`); } -export function applyIndirectStyles(selection, mark, scales, dimensions) { +export function applyIndirectStyles( + selection: Selection, + mark: InstantiatedMark, + scales: Scales, + dimensions: Dimensions +) { applyAttr(selection, "aria-label", mark.ariaLabel); applyAttr(selection, "aria-description", mark.ariaDescription); applyAttr(selection, "aria-hidden", mark.ariaHidden); @@ -326,33 +349,48 @@ export function applyIndirectStyles(selection, mark, scales, dimensions) { } } -export function applyDirectStyles(selection, mark) { +export function applyDirectStyles(selection: Selection, mark: InstantiatedMark) { applyStyle(selection, "mix-blend-mode", mark.mixBlendMode); applyAttr(selection, "opacity", mark.opacity); } -function applyHref(selection, href, target) { - selection.each(function (i) { +function applyHref(selection: Selection, href: (i: index & Series) => string, target?: string) { + selection.each(function (this: SVGElement, i) { const h = href(i); if (h != null) { const a = this.ownerDocument.createElementNS(namespaces.svg, "a"); a.setAttribute("fill", "inherit"); a.setAttributeNS(namespaces.xlink, "href", h); if (target != null) a.setAttribute("target", target); - this.parentNode.insertBefore(a, this).appendChild(this); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.parentNode!.insertBefore(a, this).appendChild(this); } }); } -export function applyAttr(selection, name, value) { +export function applyAttr( + selection: Selection, + name: string, + value?: number | string | null | ((i: index & Series) => number | string) +) { if (value != null) selection.attr(name, value); } -export function applyStyle(selection, name, value) { +export function applyStyle( + selection: Selection, + name: string, + value?: number | string | null | ((i: index & Series) => number | string) +) { if (value != null) selection.style(name, value); } -export function applyTransform(selection, mark, {x, y}, tx = offset, ty = offset) { +export function applyTransform( + selection: Selection, + mark: InstantiatedMark, + {x, y}: Scales, + tx = offset, + ty = offset +) { tx += mark.dx; ty += mark.dy; if (x?.bandwidth) tx += x.bandwidth() / 2; @@ -360,25 +398,28 @@ export function applyTransform(selection, mark, {x, y}, tx = offset, ty = offset if (tx || ty) selection.attr("transform", `translate(${tx},${ty})`); } -export function impliedString(value, impliedValue) { - if ((value = string(value)) !== impliedValue) return value; +export function impliedString( + value: number | string | number[] | null | undefined, + impliedValue: string +): string | null | undefined { + if ((value = string(value)) !== impliedValue) return value as string | null | undefined; } -export function impliedNumber(value, impliedValue) { - if ((value = number(value)) !== impliedValue) return value; +export function impliedNumber(value: Datum, impliedValue: number): number | null | undefined { + if ((value = number(value)) !== impliedValue) return value as number | null | undefined; } const validClassName = /^-?([_a-z]|[\240-\377]|\\[0-9a-f]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])([_a-z0-9-]|[\240-\377]|\\[0-9a-f]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])*$/; -export function maybeClassName(name) { +export function maybeClassName(name: string | undefined) { if (name === undefined) return `plot-${Math.random().toString(16).slice(2)}`; name = `${name}`; if (!validClassName.test(name)) throw new Error(`invalid class name: ${name}`); return name; } -export function applyInlineStyles(selection, style) { +export function applyInlineStyles(selection: Selection, style: string | CSSStyleDeclaration) { if (typeof style === "string") { selection.property("style", style); } else if (style != null) { @@ -388,16 +429,19 @@ export function applyInlineStyles(selection, style) { } } -export function applyFrameAnchor({frameAnchor}, {width, height, marginTop, marginRight, marginBottom, marginLeft}) { +export function applyFrameAnchor( + {frameAnchor}: InstantiatedMark, + {width, height, marginTop, marginRight, marginBottom, marginLeft}: Dimensions +) { return [ - /left$/.test(frameAnchor) + /left$/.test(frameAnchor as string) ? marginLeft - : /right$/.test(frameAnchor) + : /right$/.test(frameAnchor as string) ? width - marginRight : (marginLeft + width - marginRight) / 2, - /^top/.test(frameAnchor) + /^top/.test(frameAnchor as string) ? marginTop - : /^bottom/.test(frameAnchor) + : /^bottom/.test(frameAnchor as string) ? height - marginBottom : (marginTop + height - marginBottom) / 2 ]; diff --git a/src/symbols.js b/src/symbols.ts similarity index 73% rename from src/symbols.js rename to src/symbols.ts index 8c02b900f9..2003efcc43 100644 --- a/src/symbols.js +++ b/src/symbols.ts @@ -1,10 +1,13 @@ +import type {MaybeSymbol, SymbolObject} from "./api.js"; +import type {Datum} from "./data.js"; + import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; export const sqrt3 = Math.sqrt(3); export const sqrt4_3 = 2 / sqrt3; -const symbolHexagon = { +const symbolHexagon: SymbolObject = { draw(context, size) { const rx = Math.sqrt(size / Math.PI), ry = rx * sqrt4_3, @@ -19,7 +22,7 @@ const symbolHexagon = { } }; -const symbols = new Map([ +const symbols = new Map([ ["asterisk", symbolAsterisk], ["circle", symbolCircle], ["cross", symbolCross], @@ -36,24 +39,25 @@ const symbols = new Map([ ["wye", symbolWye] ]); -function isSymbolObject(value) { - return value && typeof value.draw === "function"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isSymbolObject(value: any) { + return value && typeof (value as SymbolObject).draw === "function"; } -export function isSymbol(value) { +export function isSymbol(value: MaybeSymbol) { if (isSymbolObject(value)) return true; if (typeof value !== "string") return false; return symbols.has(value.toLowerCase()); } -export function maybeSymbol(symbol) { +export function maybeSymbol(symbol: MaybeSymbol) { if (symbol == null || isSymbolObject(symbol)) return symbol; const value = symbols.get(`${symbol}`.toLowerCase()); if (value) return value; throw new Error(`invalid symbol: ${symbol}`); } -export function maybeSymbolChannel(symbol) { +export function maybeSymbolChannel(symbol: MaybeSymbol) { if (symbol == null || isSymbolObject(symbol)) return [undefined, symbol]; if (typeof symbol === "string") { const value = symbols.get(`${symbol}`.toLowerCase()); diff --git a/src/transforms/basic.js b/src/transforms/basic.js deleted file mode 100644 index 63c780ea73..0000000000 --- a/src/transforms/basic.js +++ /dev/null @@ -1,130 +0,0 @@ -import {randomLcg} from "d3"; -import {ascendingDefined, descendingDefined} from "../defined.js"; -import {arrayify, isDomainSort, isOptions, maybeValue, valueof} from "../options.js"; - -// If both t1 and t2 are defined, returns a composite transform that first -// applies t1 and then applies t2. -export function basic({filter: f1, sort: s1, reverse: r1, transform: t1, initializer: i1, ...options} = {}, t2) { - if (t1 === undefined) { - // explicit transform overrides filter, sort, and reverse - if (f1 != null) t1 = filterTransform(f1); - if (s1 != null && !isDomainSort(s1)) t1 = composeTransform(t1, sortTransform(s1)); - if (r1) t1 = composeTransform(t1, reverseTransform); - } - if (t2 != null && i1 != null) throw new Error("transforms cannot be applied after initializers"); - return { - ...options, - ...((s1 === null || isDomainSort(s1)) && {sort: s1}), - transform: composeTransform(t1, t2) - }; -} - -// If both i1 and i2 are defined, returns a composite initializer that first -// applies i1 and then applies i2. -export function initializer({filter: f1, sort: s1, reverse: r1, initializer: i1, ...options} = {}, i2) { - if (i1 === undefined) { - // explicit initializer overrides filter, sort, and reverse - if (f1 != null) i1 = filterTransform(f1); - if (s1 != null && !isDomainSort(s1)) i1 = composeInitializer(i1, sortTransform(s1)); - if (r1) i1 = composeInitializer(i1, reverseTransform); - } - return { - ...options, - initializer: composeInitializer(i1, i2) - }; -} - -function composeTransform(t1, t2) { - if (t1 == null) return t2 === null ? undefined : t2; - if (t2 == null) return t1 === null ? undefined : t1; - return function (data, facets) { - ({data, facets} = t1.call(this, data, facets)); - return t2.call(this, arrayify(data), facets); - }; -} - -function composeInitializer(i1, i2) { - if (i1 == null) return i2 === null ? undefined : i2; - if (i2 == null) return i1 === null ? undefined : i1; - return function (data, facets, channels, scales, dimensions) { - let c1, d1, f1, c2, d2, f2; - ({data: d1 = data, facets: f1 = facets, channels: c1} = i1.call(this, data, facets, channels, scales, dimensions)); - ({data: d2 = d1, facets: f2 = f1, channels: c2} = i2.call(this, d1, f1, {...channels, ...c1}, scales, dimensions)); - return {data: d2, facets: f2, channels: {...c1, ...c2}}; - }; -} - -function apply(options, t) { - return (options.initializer != null ? initializer : basic)(options, t); -} - -export function filter(value, options) { - return apply(options, filterTransform(value)); -} - -function filterTransform(value) { - return (data, facets) => { - const V = valueof(data, value); - return {data, facets: facets.map((I) => I.filter((i) => V[i]))}; - }; -} - -export function reverse(options) { - return {...apply(options, reverseTransform), sort: null}; -} - -function reverseTransform(data, facets) { - return {data, facets: facets.map((I) => I.slice().reverse())}; -} - -export function shuffle({seed, ...options} = {}) { - return {...apply(options, sortValue(seed == null ? Math.random : randomLcg(seed))), sort: null}; -} - -export function sort(value, options) { - return { - ...(isOptions(value) && value.channel !== undefined ? initializer : apply)(options, sortTransform(value)), - sort: null - }; -} - -function sortTransform(value) { - return (typeof value === "function" && value.length !== 1 ? sortData : sortValue)(value); -} - -function sortData(compare) { - return (data, facets) => { - const compareData = (i, j) => compare(data[i], data[j]); - return {data, facets: facets.map((I) => I.slice().sort(compareData))}; - }; -} - -function sortValue(value) { - let channel, order; - ({channel, value, order = ascendingDefined} = {...maybeValue(value)}); - if (typeof order !== "function") { - switch (`${order}`.toLowerCase()) { - case "ascending": - order = ascendingDefined; - break; - case "descending": - order = descendingDefined; - break; - default: - throw new Error(`invalid order: ${order}`); - } - } - return (data, facets, channels) => { - let V; - if (channel === undefined) { - V = valueof(data, value); - } else { - if (channels === undefined) throw new Error("channel sort requires an initializer"); - V = channels[channel]; - if (!V) return {}; // ignore missing channel - V = V.value; - } - const compareValue = (i, j) => order(V[i], V[j]); - return {data, facets: facets.map((I) => I.slice().sort(compareValue))}; - }; -} diff --git a/src/transforms/basic.ts b/src/transforms/basic.ts new file mode 100644 index 0000000000..688ba3cb07 --- /dev/null +++ b/src/transforms/basic.ts @@ -0,0 +1,193 @@ +import type { + Comparator, + Dimensions, + InitializerOption, + InstantiatedMark, + MarkOptions, + Scales, + ShuffleOptions, + SortOption, + TransformFunction, + TransformOption +} from "../api.js"; +import type {DataArray, Datum, index, Series, ValueArray} from "../data.js"; + +import type {Accessor} from "../options.js"; + +import {randomLcg} from "d3"; +import {ascendingDefined, descendingDefined} from "../defined.js"; +import {arrayify, isDomainSort, isOptions, maybeValue, valueof} from "../options.js"; + +// If both t1 and t2 are defined, returns a composite transform that first +// applies t1 and then applies t2. +/** + +#### Plot.transform(*options*, *transform*) + +Given an *options* object that may specify some basic transforms (*filter*, *sort*, or *reverse*) or a custom *transform* function, composes those transforms if any with the given *transform* function, returning a new *options* object. If a custom *transform* function is present on the given *options*, any basic transforms are ignored. Any additional input *options* are passed through in the returned *options* object. This method facilitates applying the basic transforms prior to applying the given custom *transform* and is used internally by Plot’s built-in transforms. + +@link https://github.com/observablehq/plot/blob/main/README.md#plottransformoptions-transform + + */ +export function basic( + {filter: f1, sort: s1, reverse: r1, transform: t1, initializer: i1, ...options}: MarkOptions = {}, + t2: TransformOption +): MarkOptions { + if (t1 === undefined) { + // explicit transform overrides filter, sort, and reverse + if (f1 != null) t1 = filterTransform(f1); + if (s1 != null && !isDomainSort(s1)) t1 = composeTransform(t1, sortTransform(s1) as TransformOption); + if (r1) t1 = composeTransform(t1, reverseTransform); + } + if (t2 != null && i1 != null) throw new Error("transforms cannot be applied after initializers"); + + return { + ...options, + ...((s1 === null || isDomainSort(s1)) && {sort: s1}), + transform: composeTransform(t1, t2) + }; +} + +// If both i1 and i2 are defined, returns a composite initializer that first +// applies i1 and then applies i2. +export function initializer( + {filter: f1, sort: s1, reverse: r1, initializer: i1, ...options}: MarkOptions = {}, + i2: InitializerOption +): MarkOptions { + if (i1 === undefined) { + // explicit initializer overrides filter, sort, and reverse + if (f1 != null) i1 = filterTransform(f1); + if (s1 != null && !isDomainSort(s1)) i1 = composeInitializer(i1, sortTransform(s1) as TransformOption); + if (r1) i1 = composeInitializer(i1, reverseTransform); + } + return { + ...options, + initializer: composeInitializer(i1, i2) + }; +} + +function composeTransform(t1: TransformOption, t2: TransformOption): TransformOption { + if (t1 == null) return t2 === null ? undefined : t2; + if (t2 == null) return t1 === null ? undefined : t1; + return function (this: InstantiatedMark, data: DataArray, facets: Series[]) { + ({data, facets} = t1.call(this, data, facets)); + return t2.call(this, arrayify(data), facets); // I believe this arrayify to be useless + }; +} + +function composeInitializer(i1: InitializerOption, i2: InitializerOption): InitializerOption { + if (i1 == null) return i2 === null ? undefined : i2; + if (i2 == null) return i1 === null ? undefined : i1; + return function (data, facets, channels, scales: Scales, dimensions?: Dimensions) { + let c1, d1, f1, c2, d2, f2; + // eslint-disable-next-line prefer-const + ({data: d1 = data, facets: f1 = facets, channels: c1} = i1.call(this, data, facets, channels, scales, dimensions)); + // eslint-disable-next-line prefer-const + ({data: d2 = d1, facets: f2 = f1, channels: c2} = i2.call(this, d1, f1, {...channels, ...c1}, scales, dimensions)); + return {data: d2, facets: f2, channels: {...c1, ...c2}}; + }; +} + +function apply(options: MarkOptions, t: TransformOption | InitializerOption): MarkOptions { + return options.initializer != null ? initializer(options, t) : basic(options, t as TransformOption); +} + +/** + * Plot.filter(test, options) + * + * Filters the data given the specified _test_. The test can be given as an accessor function + * (which receives the _datum_ and _index_), or as a channel value definition such as a field + * name; truthy values are retained. + */ +export function filter(value: Accessor, options: MarkOptions) { + return apply(options, filterTransform(value)); +} + +function filterTransform(value: Accessor): TransformFunction { + return (data: DataArray, facets: Series[]) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const V = valueof(data, value)!; + return {data, facets: facets.map((I) => I.filter((i) => V[i]))}; + }; +} + +export function reverse(options: MarkOptions): MarkOptions { + return {...apply(options, reverseTransform), sort: null}; +} + +function reverseTransform(data: DataArray, facets: Series[]) { + return {data, facets: facets.map((I) => I.slice().reverse())}; +} + +/** + * Shuffles the data randomly. If a *seed* option is specified, a linear congruential + * generator with the given seed is used to generate random numbers deterministically; + * otherwise, Math.random is used. + * @link https://github.com/observablehq/plot/blob/main/README.md#plotshuffleoptions + */ +export function shuffle({seed, ...options}: ShuffleOptions & MarkOptions = {}): MarkOptions { + return { + ...apply(options, sortValue(seed == null ? Math.random : randomLcg(seed)) as TransformOption), + sort: null + }; +} + +export function sort(value: SortOption, options: MarkOptions) { + return { + ...(isOptions(value) && value.channel !== undefined + ? initializer(options, sortTransform(value) as InitializerOption) + : apply(options, sortTransform(value) as TransformOption)), + sort: null + }; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +function sortTransform(value: SortOption): TransformOption | {} { + return typeof value === "function" && value.length !== 1 ? sortData(value as Comparator) : sortValue(value); +} + +function sortData(compare: Comparator): TransformOption { + return (data: DataArray, facets: Series[]) => { + const compareData = (i: index, j: index) => compare(data[i], data[j]); + return {data, facets: facets.map((I) => I.slice().sort(compareData))}; + }; +} + +type ValueChannels = "x" | "y" | "r"; +type InstantiatedChannel = {value: ValueArray}; +type InstantiatedChannels = Record; + +// eslint-disable-next-line @typescript-eslint/ban-types +function sortValue(v: SortOption): TransformOption | InitializerOption | {} { + let { + channel, // eslint-disable-line prefer-const + value, // eslint-disable-line prefer-const + order = ascendingDefined + } = maybeValue(v); + if (typeof order !== "function") { + switch (`${order}`.toLowerCase()) { + case "ascending": + order = ascendingDefined as Comparator; + break; + case "descending": + order = descendingDefined as Comparator; + break; + default: + throw new Error(`invalid order: ${order}`); + } + } + return (data: DataArray, facets: Series[], channels?: InstantiatedChannels) => { + let V: ValueArray; + if (channel === undefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + V = valueof(data, value)!; + } else { + if (channels === undefined) throw new Error("channel sort requires an initializer"); + const V0 = channels[channel as ValueChannels]; // TODO + if (!V0) return {}; // ignore missing channel + V = V0.value; + } + const compareValue = (i: index, j: index) => (order as Comparator)(V[i], V[j]); + return {data, facets: facets.map((I) => I.slice().sort(compareValue))}; + }; +} diff --git a/src/transforms/bin.js b/src/transforms/bin.ts similarity index 59% rename from src/transforms/bin.js rename to src/transforms/bin.ts index 60e8540747..02600772cf 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.ts @@ -1,3 +1,10 @@ +import type {MarkOptions, OutputOptions, BinValue} from "../api.js"; +import type {DataArray, Datum, index, Series, Value} from "../data.js"; +import type {Accessor, GetColumn} from "../options.js"; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3"; import { valueof, @@ -31,62 +38,66 @@ import {maybeInsetX, maybeInsetY} from "./inset.js"; import {maybeInterval} from "./interval.js"; // Group on {z, fill, stroke}, then optionally on y, then bin x. -export function binX(outputs = {y: "count"}, options = {}) { +export function binX(outputs: OutputOptions = {y: "count"}, options: MarkOptions = {}) { [outputs, options] = mergeOptions(outputs, options); const {x, y} = options; return binn(maybeBinValue(x, options, identity), null, null, y, outputs, maybeInsetX(options)); } // Group on {z, fill, stroke}, then optionally on x, then bin y. -export function binY(outputs = {x: "count"}, options = {}) { +export function binY(outputs: OutputOptions = {x: "count"}, options: MarkOptions = {}) { [outputs, options] = mergeOptions(outputs, options); const {x, y} = options; return binn(null, maybeBinValue(y, options, identity), x, null, outputs, maybeInsetY(options)); } // Group on {z, fill, stroke}, then bin on x and y. -export function bin(outputs = {fill: "count"}, options = {}) { +export function bin(outputs: OutputOptions = {fill: "count"}, options: MarkOptions = {}) { [outputs, options] = mergeOptions(outputs, options); const {x, y} = maybeBinValueTuple(options); return binn(x, y, null, null, outputs, maybeInsetX(maybeInsetY(options))); } -function maybeDenseInterval(bin, k, options = {}) { +function maybeDenseInterval( + bin: (outputs: OutputOptions, options: MarkOptions) => MarkOptions, + k: "x" | "y", + options: MarkOptions = {} +) { return options?.interval == null ? options : bin({[k]: options?.reduce === undefined ? reduceFirst : options.reduce, filter: null}, options); } -export function maybeDenseIntervalX(options) { +export function maybeDenseIntervalX(options: MarkOptions) { return maybeDenseInterval(binX, "y", options); } -export function maybeDenseIntervalY(options) { +export function maybeDenseIntervalY(options: MarkOptions) { return maybeDenseInterval(binY, "x", options); } -function binn( - bx, // optionally bin on x (exclusive with gx) - by, // optionally bin on y (exclusive with gy) - gx, // optionally group on x (exclusive with bx and gy) - gy, // optionally group on y (exclusive with by and gx) +function binn( + bx0: BinValue | null, // optionally bin on x (exclusive with gx) + by0: BinValue | null, // optionally bin on y (exclusive with gy) + gx: number | Accessor | null | undefined, // optionally group on x (exclusive with bx and gy) + gy: number | Accessor | null | undefined, // optionally group on y (exclusive with by and gx) { data: reduceData = reduceIdentity, - filter = reduceCount, // return only non-empty bins by default - sort, + filter: filter0 = reduceCount, // return only non-empty bins by default + sort: sort0, reverse, - ...outputs // output channel definitions - } = {}, - inputs = {} // input channels and options -) { - bx = maybeBin(bx); - by = maybeBin(by); + ...outputs0 // output channel definitions + }: OutputOptions = {}, + inputs: MarkOptions = {} // input channels and options +): MarkOptions { + const bx = maybeBin(bx0); // TODO change name bx1 is confusing + const by = maybeBin(by0); // Compute the outputs. - outputs = maybeOutputs(outputs, inputs); + const outputs = maybeOutputs(outputs0, inputs); reduceData = maybeReduce(reduceData, identity); - sort = sort == null ? undefined : maybeOutput("sort", sort, inputs); - filter = filter == null ? undefined : maybeEvaluator("filter", filter, inputs); + const sort = sort0 == null ? undefined : maybeOutput("sort", sort0, inputs); + const filter = filter0 == null ? undefined : maybeEvaluator("filter", filter0, inputs); // Don’t group on a channel if an output requires it as an input! if (gx != null && hasOutput(outputs, "x", "x1", "x2")) gx = null; @@ -99,7 +110,7 @@ function binn( const [BY2, setBY2] = maybeColumn(by); // Produce x or y output channels as appropriate (when grouping). - const [k, gk] = gx != null ? [gx, "x"] : gy != null ? [gy, "y"] : []; + const [k, gk]: [(number | Accessor | null)?, string?] = gx != null ? [gx, "x"] : gy != null ? [gy, "y"] : []; const [GK, setGK] = maybeColumn(k); // Greedily materialize the z, fill, and stroke channels (if channels and not @@ -140,16 +151,16 @@ function binn( const G = maybeSubgroup(outputs, {z: Z, fill: F, stroke: S}); const groupFacets = []; const groupData = []; - const GK = K && setGK([]); - const GZ = Z && setGZ([]); - const GF = F && setGF([]); - const GS = S && setGS([]); - const BX = bx ? bx(data) : [[, , (I) => I]]; - const BY = by ? by(data) : [[, , (I) => I]]; - const BX1 = bx && setBX1([]); - const BX2 = bx && setBX2([]); - const BY1 = by && setBY1([]); - const BY2 = by && setBY2([]); + const GK = K && (setGK!([]) as Value[]); // For .push; TODO: type setColumn? + const GZ = Z && (setGZ!([]) as Value[]); + const GF = F && (setGF!([]) as Value[]); + const GS = S && (setGS!([]) as Value[]); + const BX = bx ? bx(data) : ([[, , (I: Series) => I]] as [BinFilter]); + const BY = by ? by(data) : ([[, , (I: Series) => I]] as [BinFilter]); + const BX1 = bx && (setBX1!([]) as Value[]); + const BX2 = bx && (setBX2!([]) as Value[]); + const BY1 = by && (setBY1!([]) as Value[]); + const BY2 = by && (setBY2!([]) as Value[]); let i = 0; for (const o of outputs) o.initialize(data); if (sort) sort.initialize(data); @@ -169,12 +180,12 @@ function binn( if (filter && !filter.reduce(b, extent)) continue; groupFacet.push(i++); groupData.push(reduceData.reduce(b, data, extent)); - if (K) GK.push(k); - if (Z) GZ.push(G === Z ? f : Z[b[0]]); - if (F) GF.push(G === F ? f : F[b[0]]); - if (S) GS.push(G === S ? f : S[b[0]]); - if (BX1) BX1.push(x1), BX2.push(x2); - if (BY1) BY1.push(y1), BY2.push(y2); + if (K) GK!.push(k); + if (Z) GZ!.push(G === Z ? f : Z[b[0]]); + if (F) GF!.push(G === F ? f : F[b[0]]); + if (S) GS!.push(G === S ? f : S[b[0]]); + if (BX1) BX1.push(x1), (BX2 as Value[]).push(x2); + if (BY1) BY1.push(y1), (BY2 as Value[]).push(y2); for (const o of outputs) o.reduce(b, extent); if (sort) sort.reduce(b); } @@ -186,80 +197,96 @@ function binn( maybeSort(groupFacets, sort, reverse); return {data: groupData, facets: groupFacets}; }), - ...(!hasOutput(outputs, "x") && (BX1 ? {x1: BX1, x2: BX2, x: mid(BX1, BX2)} : {x, x1, x2})), - ...(!hasOutput(outputs, "y") && (BY1 ? {y1: BY1, y2: BY2, y: mid(BY1, BY2)} : {y, y1, y2})), - ...(GK && {[gk]: GK}), + ...(!hasOutput(outputs, "x") && (BX1 ? {x1: BX1, x2: BX2, x: mid(BX1, BX2 as GetColumn)} : {x, x1, x2})), + ...(!hasOutput(outputs, "y") && (BY1 ? {y1: BY1, y2: BY2, y: mid(BY1, BY2 as GetColumn)} : {y, y1, y2})), + ...(GK && {[gk as string]: GK}), ...Object.fromEntries(outputs.map(({name, output}) => [name, output])) }; } // Allow bin options to be specified as part of outputs; merge them into options. -function mergeOptions({cumulative, domain, thresholds, interval, ...outputs}, options) { +function mergeOptions( + {cumulative, domain, thresholds, interval, ...outputs}: OutputOptions, + options: MarkOptions +): [OutputOptions, MarkOptions] { return [outputs, {cumulative, domain, thresholds, interval, ...options}]; } -function maybeBinValue(value, {cumulative, domain, thresholds, interval}, defaultValue) { - value = {...maybeValue(value)}; +function maybeBinValue( + value0: BinValue | Accessor | number | undefined, + {cumulative, domain, thresholds, interval}: MarkOptions, + defaultValue?: Accessor +) { + const value = (maybeValue(value0) || {}) as BinValue; if (value.domain === undefined) value.domain = domain; if (value.cumulative === undefined) value.cumulative = cumulative; if (value.thresholds === undefined) value.thresholds = thresholds; if (value.interval === undefined) value.interval = interval; if (value.value === undefined) value.value = defaultValue; value.thresholds = maybeThresholds(value.thresholds, value.interval); + return value; } -function maybeBinValueTuple(options) { - let {x, y} = options; - x = maybeBinValue(x, options); - y = maybeBinValue(y, options); - [x.value, y.value] = maybeTuple(x.value, y.value); - return {x, y}; +function maybeBinValueTuple(options: MarkOptions) { + const {x, y} = options; + const x1 = maybeBinValue(x, options); + const y1 = maybeBinValue(y, options); + [x1.value, y1.value] = maybeTuple(x1.value, y1.value); + return {x: x1, y: y1}; } -function maybeBin(options) { +type Bin = [{x0: number; x1: number}, Set]; +type BinFilter = [number | undefined, number | undefined, (I: Series) => Series]; + +/* : ((data: DataArray) => BinFilter[]) | undefined */ +function maybeBin(options: BinValue | null) { if (options == null) return; const {value, cumulative, domain = extent, thresholds} = options; - const bin = (data) => { - let V = valueof(data, value, Array); // d3.bin prefers Array input - const bin = binner().value((i) => V[i]); + const bin = (data: DataArray) => { + let V = valueof(data, value, Array)! as Value[]; // d3.bin prefers Array input + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const bin = binner().value((i: index) => V[i]); // TODO: @d3 types are wrong on this if (isTemporal(V) || isTimeThresholds(thresholds)) { V = V.map(coerceDate); - let [min, max] = typeof domain === "function" ? domain(V) : domain; + let [min, max] = typeof domain === "function" ? domain(V as Date[]) : domain; let t = typeof thresholds === "function" && !isInterval(thresholds) ? thresholds(V, min, max) : thresholds; - if (typeof t === "number") t = utcTickInterval(min, max, t); + if (typeof t === "number") t = utcTickInterval(min as unknown as Date, max as unknown as Date, t); if (isInterval(t)) { if (domain === extent) { min = t.floor(min); - max = t.ceil(new Date(+max + 1)); + max = t.ceil(new Date(+(max as number) + 1)); } t = t.range(min, max); } - bin.thresholds(t).domain([min, max]); + bin.thresholds(t).domain([min as number, max as number]); } else { V = V.map(coerceNumber); let d = domain; let t = thresholds; if (isInterval(t)) { - let [min, max] = typeof d === "function" ? d(V) : d; + let [min, max] = typeof d === "function" ? d(V as number[]) : d!; if (d === extent) { min = t.floor(min); max = t.offset(t.floor(max)); - d = [min, max]; + d = [min as number, max as number]; } t = t.range(min, max); } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore bin.thresholds(t).domain(d); } let bins = bin(range(data)).map(binset); - if (cumulative) bins = (cumulative < 0 ? bins.reverse() : bins).map(bincumset); + if (cumulative) bins = ((cumulative as number) < 0 ? bins.reverse() : bins).map(bincumset); return bins.map(binfilter); }; bin.label = labelof(value); return bin; } -function maybeThresholds(thresholds, interval) { +function maybeThresholds(thresholds: any, interval: any) { if (thresholds === undefined) { return interval === undefined ? thresholdAuto : maybeRangeInterval(interval); } @@ -280,33 +307,33 @@ function maybeThresholds(thresholds, interval) { } // Unlike the interval transform, we require a range method, too. -function maybeRangeInterval(interval) { +function maybeRangeInterval(interval: any) { interval = maybeInterval(interval); if (!isInterval(interval)) throw new Error(`invalid interval: ${interval}`); return interval; } -function thresholdAuto(values, min, max) { +function thresholdAuto(values: ArrayLike, min: number, max: number) { return Math.min(200, thresholdScott(values, min, max)); } -function isTimeThresholds(t) { +function isTimeThresholds(t: any) { return isTimeInterval(t) || (isIterable(t) && isTemporal(t)); } -function isTimeInterval(t) { +function isTimeInterval(t: any) { return isInterval(t) && typeof t === "function" && t() instanceof Date; } -function isInterval(t) { +function isInterval(t: any) { return t ? typeof t.range === "function" : false; } -function binset(bin) { +function binset(bin: any): Bin { return [bin, new Set(bin)]; } -function bincumset([bin], j, bins) { +function bincumset([bin]: Bin, j: number, bins: Bin[]): Bin { return [ bin, { @@ -318,7 +345,7 @@ function bincumset([bin], j, bins) { } return 0; }, - has(i) { + has(i: index) { for (let k = 0; k <= j; ++k) { if (bins[k][1].has(i)) { return true; @@ -326,12 +353,12 @@ function bincumset([bin], j, bins) { } return false; } - } + } as Set ]; } -function binfilter([{x0, x1}, set]) { - return [x0, x1, set.size ? (I) => I.filter(set.has, set) : binempty]; +function binfilter([{x0, x1}, set]: Bin): BinFilter { + return [x0, x1, set.size ? (I: Series) => I.filter(set.has, set) : binempty]; } function binempty() { diff --git a/src/transforms/group.js b/src/transforms/group.ts similarity index 56% rename from src/transforms/group.js rename to src/transforms/group.ts index 39e0aea894..86c18d50e5 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.ts @@ -1,3 +1,12 @@ +import type {AggregationMethod, Aggregate, BinExtent, MarkOptions, OutputOptions, Reducer} from "../api.js"; +import type {DataArray, Datum, index, Series, Value, ValueArray} from "../data.js"; +import type {pXX, Accessor} from "../options.js"; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +// TODO: check types of the d3.quantile function +type ArrayReducer = (I: Series, value: (i: index) => Value) => Value; + import { group as grouper, sort, @@ -33,26 +42,35 @@ import { import {basic} from "./basic.js"; // Group on {z, fill, stroke}. -export function groupZ(outputs, options) { +export function groupZ(outputs: OutputOptions, options: MarkOptions): MarkOptions { return groupn(null, null, outputs, options); } // Group on {z, fill, stroke}, then on x. -export function groupX(outputs = {y: "count"}, options = {}) { +export function groupX( + outputs: OutputOptions = {y: "count"}, + options: MarkOptions = {} +): MarkOptions { const {x = identity} = options; if (x == null) throw new Error("missing channel: x"); return groupn(x, null, outputs, options); } // Group on {z, fill, stroke}, then on y. -export function groupY(outputs = {x: "count"}, options = {}) { +export function groupY( + outputs: OutputOptions = {x: "count"}, + options: MarkOptions = {} +): MarkOptions { const {y = identity} = options; if (y == null) throw new Error("missing channel: y"); return groupn(null, y, outputs, options); } // Group on {z, fill, stroke}, then on x and y. -export function group(outputs = {fill: "count"}, options = {}) { +export function group( + outputs: OutputOptions = {fill: "count"}, + options: MarkOptions = {} +): MarkOptions { let {x, y} = options; [x, y] = maybeTuple(x, y); if (x == null) throw new Error("missing channel: x"); @@ -60,23 +78,23 @@ export function group(outputs = {fill: "count"}, options = {}) { return groupn(x, y, outputs, options); } -function groupn( - x, // optionally group on x - y, // optionally group on y +function groupn( + x: number | Accessor | null | undefined, // optionally group on x + y: number | Accessor | null | undefined, // optionally group on y { - data: reduceData = reduceIdentity, - filter, - sort, + data: reduceData = reduceIdentity, // TODO: not tested and not documented (https://github.com/observablehq/plot/pull/272) + filter: filter0, + sort: sort0, reverse, - ...outputs // output channel definitions - } = {}, - inputs = {} // input channels and options + ...outputs0 // output channel definitions + }: OutputOptions = {}, + inputs: MarkOptions = {} // input channels and options ) { // Compute the outputs. - outputs = maybeOutputs(outputs, inputs); + const outputs = maybeOutputs(outputs0, inputs); reduceData = maybeReduce(reduceData, identity); - sort = sort == null ? undefined : maybeOutput("sort", sort, inputs); - filter = filter == null ? undefined : maybeEvaluator("filter", filter, inputs); + const sort = sort0 == null ? undefined : maybeOutput("sort", sort0, inputs); + const filter = filter0 == null ? undefined : maybeEvaluator("filter", filter0, inputs); // Produce x and y output channels as appropriate. const [GX, setGX] = maybeColumn(x); @@ -114,11 +132,11 @@ function groupn( const G = maybeSubgroup(outputs, {z: Z, fill: F, stroke: S}); const groupFacets = []; const groupData = []; - const GX = X && setGX([]); - const GY = Y && setGY([]); - const GZ = Z && setGZ([]); - const GF = F && setGF([]); - const GS = S && setGS([]); + const GX = X && (setGX!([]) as Value[]); // For .push; TODO: type setColumn? + const GY = Y && (setGY!([]) as Value[]); + const GZ = Z && (setGZ!([]) as Value[]); + const GF = F && (setGF!([]) as Value[]); + const GS = S && (setGS!([]) as Value[]); let i = 0; for (const o of outputs) o.initialize(data); if (sort) sort.initialize(data); @@ -134,11 +152,11 @@ function groupn( if (filter && !filter.reduce(g)) continue; groupFacet.push(i++); groupData.push(reduceData.reduce(g, data)); - if (X) GX.push(x); - if (Y) GY.push(y); - if (Z) GZ.push(G === Z ? f : Z[g[0]]); - if (F) GF.push(G === F ? f : F[g[0]]); - if (S) GS.push(G === S ? f : S[g[0]]); + if (X) GX!.push(x); + if (Y) GY!.push(y); + if (Z) GZ!.push(G === Z ? f : Z[g[0]]); + if (F) GF!.push(G === F ? f : F[g[0]]); + if (S) GS!.push(G === S ? f : S[g[0]]); for (const o of outputs) o.reduce(g); if (sort) sort.reduce(g); } @@ -155,7 +173,7 @@ function groupn( }; } -export function hasOutput(outputs, ...names) { +export function hasOutput(outputs: Reducer[], ...names: Array["name"]>) { for (const {name} of outputs) { if (names.includes(name)) { return true; @@ -164,17 +182,17 @@ export function hasOutput(outputs, ...names) { return false; } -export function maybeOutputs(outputs, inputs) { +export function maybeOutputs(outputs: OutputOptions, inputs: MarkOptions) { const entries = Object.entries(outputs); // Propagate standard mark channels by default. if (inputs.title != null && outputs.title === undefined) entries.push(["title", reduceTitle]); if (inputs.href != null && outputs.href === undefined) entries.push(["href", reduceFirst]); return entries.map(([name, reduce]) => { return reduce == null ? {name, initialize() {}, scope() {}, reduce() {}} : maybeOutput(name, reduce, inputs); - }); + }) as Reducer[]; } -export function maybeOutput(name, reduce, inputs) { +export function maybeOutput(name: string, reduce: AggregationMethod, inputs: MarkOptions) { const evaluator = maybeEvaluator(name, reduce, inputs); const [output, setOutput] = column(evaluator.label); let O; @@ -185,39 +203,39 @@ export function maybeOutput(name, reduce, inputs) { evaluator.initialize(data); O = setOutput([]); }, - scope(scope, I) { + scope(scope, I: Series) { evaluator.scope(scope, I); }, - reduce(I, extent) { + reduce(I: Series, extent: BinExtent) { O.push(evaluator.reduce(I, extent)); } - }; + } as Reducer; } -export function maybeEvaluator(name, reduce, inputs) { +export function maybeEvaluator(name: string, reduce: AggregationMethod, inputs: MarkOptions) { const input = maybeInput(name, inputs); const reducer = maybeReduce(reduce, input); - let V, context; + let V: ValueArray, context: Value | null | undefined; return { label: labelof(reducer === reduceCount ? null : input, reducer.label), - initialize(data) { - V = input === undefined ? data : valueof(data, input); + initialize(data: DataArray) { + V = (input === undefined ? data : valueof(data, input)) as ValueArray; if (reducer.scope === "data") { context = reducer.reduce(range(data), V); } }, - scope(scope, I) { + scope(scope: Aggregate["scope"], I: Series) { if (reducer.scope === scope) { context = reducer.reduce(I, V); } }, - reduce(I, extent) { + reduce(I: Series, extent?: BinExtent) { return reducer.scope == null ? reducer.reduce(I, V, extent) : reducer.reduce(I, V, context, extent); } }; } -export function maybeGroup(I, X) { +export function maybeGroup(I: Series, X: ValueArray | null | undefined): [Value, Series][] { return X ? sort( grouper(I, (i) => X[i]), @@ -226,10 +244,10 @@ export function maybeGroup(I, X) { : [[, I]]; } -export function maybeReduce(reduce, value) { - if (reduce && typeof reduce.reduce === "function") return reduce; +export function maybeReduce(reduce: AggregationMethod, value: Accessor): Aggregate { + if (reduce && typeof reduce.reduce === "function") return reduce as Aggregate; if (typeof reduce === "function") return reduceFunction(reduce); - if (/^p\d{2}$/i.test(reduce)) return reduceAccessor(percentile(reduce)); + if (/^p\d{2}$/i.test(reduce as string)) return reduceAccessor(percentile(reduce as pXX) as ArrayReducer); switch (`${reduce}`.toLowerCase()) { case "first": return reduceFirst; @@ -262,7 +280,7 @@ export function maybeReduce(reduce, value) { case "variance": return reduceAccessor(variance); case "mode": - return reduceAccessor(mode); + return reduceAccessor(mode); // TODO: mode can return a string case "x": return reduceX; case "x1": @@ -279,19 +297,30 @@ export function maybeReduce(reduce, value) { throw new Error(`invalid reduce: ${reduce}`); } -export function maybeSubgroup(outputs, inputs) { +export function maybeSubgroup( + outputs: Reducer[], + inputs: { + z?: ValueArray | null; + fill?: ValueArray | null; + stroke?: ValueArray | null; + } +) { for (const name in inputs) { - const value = inputs[name]; + const value = inputs[name as "z" | "fill" | "stroke"]; if (value !== undefined && !outputs.some((o) => o.name === name)) { return value; } } } -export function maybeSort(facets, sort, reverse) { +export function maybeSort( + facets: Series[], + sort: Reducer | undefined, + reverse: boolean | undefined +) { if (sort) { const S = sort.output.transform(); - const compare = (i, j) => ascendingDefined(S[i], S[j]); + const compare = (i: index, j: index) => ascendingDefined(S[i], S[j]); facets.forEach((f) => f.sort(compare)); } if (reverse) { @@ -299,36 +328,38 @@ export function maybeSort(facets, sort, reverse) { } } -function reduceFunction(f) { +function reduceFunction(f: (X: ValueArray, extent?: BinExtent) => Value): Aggregate { return { - reduce(I, X, extent) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + reduce(I: Series, X: ValueArray, extent?: BinExtent) { return f(take(X, I), extent); } }; } -function reduceAccessor(f) { +function reduceAccessor(f: ArrayReducer): Aggregate { return { - reduce(I, X) { - return f(I, (i) => X[i]); + reduce(I: Series, X: ValueArray) { + return f(I, (i: index) => X[i]); } }; } export const reduceIdentity = { - reduce(I, X) { + reduce(I: Series, X: ValueArray) { return take(X, I); } }; export const reduceFirst = { - reduce(I, X) { + reduce(I: Series, X: ValueArray) { return X[I[0]]; } }; const reduceTitle = { - reduce(I, X) { + reduce(I: Series, X: ValueArray) { const n = 5; const groups = sort( rollup( @@ -341,28 +372,31 @@ const reduceTitle = { const top = groups.slice(-n).reverse(); if (top.length < groups.length) { const bottom = groups.slice(0, 1 - n); - top[n - 1] = [`… ${bottom.length.toLocaleString("en-US")} more`, sum(bottom, second)]; + top[n - 1] = [ + `… ${bottom.length.toLocaleString("en-US")} more`, + sum(bottom, second as (d: [Value, number]) => number) + ]; } return top.map(([key, value]) => `${key} (${value.toLocaleString("en-US")})`).join("\n"); } }; -const reduceLast = { - reduce(I, X) { +const reduceLast: Aggregate = { + reduce(I: Series, X: ValueArray) { return X[I[I.length - 1]]; } }; -export const reduceCount = { +export const reduceCount: Aggregate = { label: "Frequency", - reduce(I) { + reduce(I: Series) { return I.length; } }; -const reduceDistinct = { +const reduceDistinct: Aggregate = { label: "Distinct", - reduce: (I, X) => { + reduce: (I: Series, X: ValueArray) => { const s = new InternSet(); for (const i of I) s.add(X[i]); return s.size; @@ -371,49 +405,61 @@ const reduceDistinct = { const reduceSum = reduceAccessor(sum); -function reduceProportion(value, scope) { +function reduceProportion(value: Accessor, scope: Aggregate["scope"]): Aggregate { return value == null - ? {scope, label: "Frequency", reduce: (I, V, basis = 1) => I.length / basis} - : {scope, reduce: (I, V, basis = 1) => sum(I, (i) => V[i]) / basis}; + ? {scope, label: "Frequency", reduce: (I: Series, V: ValueArray, basis = 1) => I.length / (basis as number)} + : {scope, reduce: (I: Series, V: ValueArray, basis = 1) => sum(I, (i) => V[i] as number) / (basis as number)}; } -function mid(x1, x2) { - const m = (+x1 + +x2) / 2; +function mid(x1: Date | number | undefined, x2: Date | number | undefined) { + const m = (+(x1 as number) + +(x2 as number)) / 2; return x1 instanceof Date ? new Date(m) : m; } -const reduceX = { - reduce(I, X, {x1, x2}) { +const reduceX: Aggregate = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + reduce(I: Series, X: ValueArray, {x1, x2}: BinExtent) { return mid(x1, x2); } }; -const reduceY = { - reduce(I, X, {y1, y2}) { +const reduceY: Aggregate = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + reduce(I: Series, X: ValueArray, {y1, y2}: BinExtent) { return mid(y1, y2); } }; -const reduceX1 = { - reduce(I, X, {x1}) { +const reduceX1: Aggregate = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + reduce(I: Series, X: ValueArray, {x1}: BinExtent) { return x1; } }; -const reduceX2 = { - reduce(I, X, {x2}) { +const reduceX2: Aggregate = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + reduce(I: Series, X: ValueArray, {x2}: BinExtent) { return x2; } }; -const reduceY1 = { - reduce(I, X, {y1}) { +const reduceY1: Aggregate = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + reduce(I: Series, X: ValueArray, {y1}: BinExtent) { return y1; } }; -const reduceY2 = { - reduce(I, X, {y2}) { +const reduceY2: Aggregate = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + reduce(I: Series, X: ValueArray, {y2}: BinExtent) { return y2; } }; diff --git a/src/transforms/identity.js b/src/transforms/identity.ts similarity index 56% rename from src/transforms/identity.js rename to src/transforms/identity.ts index f8b521bc16..c9814c3754 100644 --- a/src/transforms/identity.js +++ b/src/transforms/identity.ts @@ -1,11 +1,14 @@ +import type {Datum} from "../data.js"; +import type {MarkOptions} from "../api.js"; + import {identity} from "../options.js"; -export function maybeIdentityX(options = {}) { +export function maybeIdentityX(options: MarkOptions = {}) { const {x, x1, x2} = options; return x1 === undefined && x2 === undefined && x === undefined ? {...options, x: identity} : options; } -export function maybeIdentityY(options = {}) { +export function maybeIdentityY(options: MarkOptions = {}) { const {y, y1, y2} = options; return y1 === undefined && y2 === undefined && y === undefined ? {...options, y: identity} : options; } diff --git a/src/transforms/inset.js b/src/transforms/inset.js deleted file mode 100644 index 1297903cd9..0000000000 --- a/src/transforms/inset.js +++ /dev/null @@ -1,19 +0,0 @@ -import {offset} from "../style.js"; - -export function maybeInsetX({inset, insetLeft, insetRight, ...options} = {}) { - [insetLeft, insetRight] = maybeInset(inset, insetLeft, insetRight); - return {inset, insetLeft, insetRight, ...options}; -} - -export function maybeInsetY({inset, insetTop, insetBottom, ...options} = {}) { - [insetTop, insetBottom] = maybeInset(inset, insetTop, insetBottom); - return {inset, insetTop, insetBottom, ...options}; -} - -function maybeInset(inset, inset1, inset2) { - return inset === undefined && inset1 === undefined && inset2 === undefined - ? offset - ? [1, 0] - : [0.5, 0.5] - : [inset1, inset2]; -} diff --git a/src/transforms/inset.ts b/src/transforms/inset.ts new file mode 100644 index 0000000000..ef756d1096 --- /dev/null +++ b/src/transforms/inset.ts @@ -0,0 +1,32 @@ +import type {Datum} from "../data.js"; +import type {MarkOptions} from "../api.js"; + +import {offset} from "../style.js"; + +export function maybeInsetX({ + inset, + insetLeft, + insetRight, + ...options +}: MarkOptions = {}): MarkOptions { + [insetLeft, insetRight] = maybeInset(inset, insetLeft, insetRight); + return {inset, insetLeft, insetRight, ...options}; +} + +export function maybeInsetY({ + inset, + insetTop, + insetBottom, + ...options +}: MarkOptions = {}): MarkOptions { + [insetTop, insetBottom] = maybeInset(inset, insetTop, insetBottom); + return {inset, insetTop, insetBottom, ...options}; +} + +function maybeInset(inset: number | undefined, inset1: number | undefined, inset2: number | undefined) { + return inset === undefined && inset1 === undefined && inset2 === undefined + ? offset + ? [1, 0] + : [0.5, 0.5] + : [inset1, inset2]; +} diff --git a/src/transforms/interval.js b/src/transforms/interval.js deleted file mode 100644 index e37f15e800..0000000000 --- a/src/transforms/interval.js +++ /dev/null @@ -1,106 +0,0 @@ -import {range} from "d3"; -import {isTemporal, labelof, map, maybeValue, valueof} from "../options.js"; -import {maybeInsetX, maybeInsetY} from "./inset.js"; - -// TODO Allow the interval to be specified as a string, e.g. “day” or “hour”? -// This will require the interval knowing the type of the associated scale to -// chose between UTC and local time (or better, an explicit timeZone option). -export function maybeInterval(interval) { - if (interval == null) return; - if (typeof interval === "number") { - const n = interval; - // Note: this offset doesn’t support the optional step argument for simplicity. - return { - floor: (d) => n * Math.floor(d / n), - offset: (d) => d + n, - range: (lo, hi) => range(Math.ceil(lo / n), hi / n).map((x) => n * x) - }; - } - if (typeof interval.floor !== "function" || typeof interval.offset !== "function") - throw new Error("invalid interval; missing floor or offset function"); - return interval; -} - -// The interval may be specified either as x: {value, interval} or as {x, -// interval}. The former is used, for example, for Plot.rect. -function maybeIntervalValue(value, {interval}) { - value = {...maybeValue(value)}; - value.interval = maybeInterval(value.interval === undefined ? interval : value.interval); - return value; -} - -function maybeIntervalK(k, maybeInsetK, options, trivial) { - const {[k]: v, [`${k}1`]: v1, [`${k}2`]: v2} = options; - const {value, interval} = maybeIntervalValue(v, options); - if (value == null || (interval == null && !trivial)) return options; - const label = labelof(v); - if (interval == null) { - let V; - const kv = {transform: (data) => V || (V = valueof(data, value)), label}; - return { - ...options, - [k]: undefined, - [`${k}1`]: v1 === undefined ? kv : v1, - [`${k}2`]: v2 === undefined ? kv : v2 - }; - } - let D1, V1; - function transform(data) { - if (V1 !== undefined && data === D1) return V1; // memoize - return (V1 = map(valueof((D1 = data), value), (v) => interval.floor(v))); - } - return maybeInsetK({ - ...options, - [k]: undefined, - [`${k}1`]: v1 === undefined ? {transform, label} : v1, - [`${k}2`]: v2 === undefined ? {transform: (data) => transform(data).map((v) => interval.offset(v)), label} : v2 - }); -} - -function maybeIntervalMidK(k, maybeInsetK, options) { - const {[k]: v} = options; - const {value, interval} = maybeIntervalValue(v, options); - if (value == null || interval == null) return options; - return maybeInsetK({ - ...options, - [k]: { - label: labelof(v), - transform: (data) => { - const V1 = map(valueof(data, value), (v) => interval.floor(v)); - const V2 = V1.map((v) => interval.offset(v)); - return V1.map( - isTemporal(V1) - ? (v1, v2) => - v1 == null || isNaN((v1 = +v1)) || ((v2 = V2[v2]), v2 == null) || isNaN((v2 = +v2)) - ? undefined - : new Date((v1 + v2) / 2) - : (v1, v2) => (v1 == null || ((v2 = V2[v2]), v2 == null) ? NaN : (+v1 + +v2) / 2) - ); - } - } - }); -} - -export function maybeTrivialIntervalX(options = {}) { - return maybeIntervalK("x", maybeInsetX, options, true); -} - -export function maybeTrivialIntervalY(options = {}) { - return maybeIntervalK("y", maybeInsetY, options, true); -} - -export function maybeIntervalX(options = {}) { - return maybeIntervalK("x", maybeInsetX, options); -} - -export function maybeIntervalY(options = {}) { - return maybeIntervalK("y", maybeInsetY, options); -} - -export function maybeIntervalMidX(options = {}) { - return maybeIntervalMidK("x", maybeInsetX, options); -} - -export function maybeIntervalMidY(options = {}) { - return maybeIntervalMidK("y", maybeInsetY, options); -} diff --git a/src/transforms/interval.ts b/src/transforms/interval.ts new file mode 100644 index 0000000000..60774ebd76 --- /dev/null +++ b/src/transforms/interval.ts @@ -0,0 +1,133 @@ +import type {Interval, IntervalObject, MarkOptions} from "../api.js"; +import type {DataArray, Datum, index, Value, ValueArray} from "../data.js"; +import type {Accessor} from "../options.js"; + +import {range} from "d3"; +import {isTemporal, labelof, map, maybeValue, valueof} from "../options.js"; +import {maybeInsetX, maybeInsetY} from "./inset.js"; + +// TODO Allow the interval to be specified as a string, e.g. “day” or “hour”? +// This will require the interval knowing the type of the associated scale to +// chose between UTC and local time (or better, an explicit timeZone option). +export function maybeInterval(interval: Interval | undefined) { + if (interval == null) return; + if (typeof interval === "number") { + const n = interval; + // Note: this offset doesn’t support the optional step argument for simplicity. + return { + floor: (d: number) => n * Math.floor(d / n), + offset: (d: number) => d + n, + range: (lo: number, hi: number) => range(Math.ceil(lo / n), hi / n).map((x) => n * x) + }; + } + if (typeof interval.floor !== "function" || typeof interval.offset !== "function") + throw new Error("invalid interval; missing floor or offset function"); + return interval; +} + +// The interval may be specified either as x: {value, interval} or as {x, +// interval}. The former is used, for example, for Plot.rect. +// @link https://github.com/observablehq/plot/blob/main/README.md#rect +type IntervalValue = {interval?: IntervalObject; value?: Accessor}; +function maybeIntervalValue( + value: number | Accessor | undefined, + {interval}: MarkOptions +): IntervalValue { + // TODO: value = {...maybeValue(value)}; + const value1 = {...(maybeValue(value) || {})}; + value1.interval = maybeInterval(value1.interval === undefined ? interval : value1.interval); + return value1; +} + +function maybeIntervalK( + k: "x" | "y", + maybeInsetK: (options: MarkOptions) => MarkOptions, + options: MarkOptions, + trivial?: true +): MarkOptions { + const {[k]: v, [`${k}1` as "x1" | "y1"]: v1, [`${k}2` as "x2" | "y2"]: v2} = options; + const {value, interval} = maybeIntervalValue(v, options); + if (value == null || (interval == null && !trivial)) return options; + const label = labelof(v); + if (interval == null) { + let V: ValueArray | null | undefined; + const kv = {transform: (data: DataArray) => V || (V = valueof(data, value)), label}; + return { + ...options, + [k]: undefined, + [`${k}1`]: v1 === undefined ? kv : v1, + [`${k}2`]: v2 === undefined ? kv : v2 + }; + } + let D1: DataArray, V1: ValueArray; + function transform(data: DataArray) { + if (V1 !== undefined && data === D1) return V1; // memoize + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (V1 = map(valueof((D1 = data), value!), (v: Datum) => interval!.floor(v as number))); + } + return maybeInsetK({ + ...options, + [k]: undefined, + [`${k}1`]: v1 === undefined ? {transform, label} : v1, + [`${k}2`]: + v2 === undefined + ? {transform: (data: DataArray) => transform(data).map((v: Datum) => interval.offset(v as number)), label} + : v2 + }); +} + +function maybeIntervalMidK( + k: "x" | "y", + maybeInsetK: (options: MarkOptions) => MarkOptions, + options: MarkOptions +) { + const {[k]: v} = options; + const {value, interval} = maybeIntervalValue(v, options); + if (value == null || interval == null) return options; + return maybeInsetK({ + ...options, + [k]: { + label: labelof(v), + transform: function (data: DataArray) { + // TODO: this transform can operate on numbers and Dates; + // Here I have to type cast everything to numbers :( + // See also type casting in map() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const V1 = map(valueof(data, value)!, (v: Datum) => interval.floor(v as number)); + const V2 = V1.map((v) => interval.offset(v as number)); + return V1.map( + isTemporal(V1) + ? (v1: Value, v2: index) => + v1 == null || isNaN((v1 = +v1)) || ((v2 = V2[v2]), v2 == null) || isNaN((v2 = +v2)) + ? (undefined as unknown as number) + : (new Date((v1 + v2) / 2) as unknown as number) + : (v1: Value, v2: index) => (v1 == null || ((v2 = V2[v2]), v2 == null) ? NaN : (+v1 + +v2) / 2) + ); + } + } + }); +} + +export function maybeTrivialIntervalX(options: MarkOptions = {}) { + return maybeIntervalK("x", maybeInsetX, options, true); +} + +export function maybeTrivialIntervalY(options: MarkOptions = {}) { + return maybeIntervalK("y", maybeInsetY, options, true); +} + +export function maybeIntervalX(options: MarkOptions = {}) { + return maybeIntervalK("x", maybeInsetX, options); +} + +export function maybeIntervalY(options: MarkOptions = {}) { + return maybeIntervalK("y", maybeInsetY, options); +} + +export function maybeIntervalMidX(options: MarkOptions = {}) { + return maybeIntervalMidK("x", maybeInsetX, options); +} + +export function maybeIntervalMidY(options: MarkOptions = {}) { + return maybeIntervalMidK("y", maybeInsetY, options); +} diff --git a/src/transforms/map.js b/src/transforms/map.ts similarity index 51% rename from src/transforms/map.js rename to src/transforms/map.ts index 6794f91f9e..2f75a469ad 100644 --- a/src/transforms/map.js +++ b/src/transforms/map.ts @@ -1,23 +1,30 @@ +import type {MapMethod, MapMethods, MarkOptions} from "../api.js"; +import type {Datum, Series, ValueArray} from "../data.js"; + import {count, group, rank} from "d3"; import {maybeZ, take, valueof, maybeInput, column} from "../options.js"; import {basic} from "./basic.js"; -export function mapX(m, options = {}) { +export function mapX(m: MapMethods, options: MarkOptions = {}) { return map( - Object.fromEntries(["x", "x1", "x2"].filter((key) => options[key] != null).map((key) => [key, m])), + Object.fromEntries( + ["x", "x1", "x2"].filter((key) => options[key as "x" | "x1" | "x2"] != null).map((key) => [key, m]) + ), options ); } -export function mapY(m, options = {}) { +export function mapY(m: MapMethods, options: MarkOptions = {}) { return map( - Object.fromEntries(["y", "y1", "y2"].filter((key) => options[key] != null).map((key) => [key, m])), + Object.fromEntries( + ["y", "y1", "y2"].filter((key) => options[key as "y" | "y1" | "y2"] != null).map((key) => [key, m]) + ), options ); } -export function map(outputs = {}, options = {}) { - const z = maybeZ(options); +export function map(outputs: {[key: string]: MapMethods} = {}, options: MarkOptions = {}) { + const z = maybeZ(options); const channels = Object.entries(outputs).map(([key, map]) => { const input = maybeInput(key, options); if (input == null) throw new Error(`missing channel: ${key}`); @@ -26,12 +33,12 @@ export function map(outputs = {}, options = {}) { }); return { ...basic(options, (data, facets) => { - const Z = valueof(data, z); + const Z = valueof(data, z); // TODO const X = channels.map(({input}) => valueof(data, input)); const MX = channels.map(({setOutput}) => setOutput(new Array(data.length))); for (const facet of facets) { for (const I of Z ? group(facet, (i) => Z[i]).values() : [facet]) { - channels.forEach(({map}, i) => map.map(I, X[i], MX[i])); + channels.forEach(({map}, i) => map.map(I, X[i] as ValueArray, MX[i])); } } return {data, facets}; @@ -40,28 +47,28 @@ export function map(outputs = {}, options = {}) { }; } -function maybeMap(map) { - if (map && typeof map.map === "function") return map; +function maybeMap(map: MapMethods): MapMethod { + if (map && typeof (map as MapMethod).map === "function") return map as MapMethod; if (typeof map === "function") return mapFunction(map); switch (`${map}`.toLowerCase()) { case "cumsum": return mapCumsum; case "rank": - return mapFunction(rank); + return mapFunction(rank as (V: ValueArray) => ValueArray); // TODO: @types/d3 should make rank work on strings case "quantile": return mapFunction(rankQuantile); } throw new Error(`invalid map: ${map}`); } -function rankQuantile(V) { +function rankQuantile(V: ValueArray) { const n = count(V) - 1; - return rank(V).map((r) => r / n); + return rank(V as number[]).map((r) => r / n); } -function mapFunction(f) { +function mapFunction(f: (V: ValueArray) => ValueArray) { return { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { const M = f(take(S, I)); if (M.length !== I.length) throw new Error("map function returned a mismatched length"); for (let i = 0, n = I.length; i < n; ++i) T[I[i]] = M[i]; @@ -70,8 +77,8 @@ function mapFunction(f) { } const mapCumsum = { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { let sum = 0; - for (const i of I) T[i] = sum += S[i]; + for (const i of I) T[i] = sum += S[i] as number; // TODO: should the cumsum map handle nulls and undefined? } }; diff --git a/src/transforms/normalize.js b/src/transforms/normalize.js deleted file mode 100644 index 3eb03bef0c..0000000000 --- a/src/transforms/normalize.js +++ /dev/null @@ -1,96 +0,0 @@ -import {extent, deviation, max, mean, median, min, sum} from "d3"; -import {defined} from "../defined.js"; -import {percentile, take} from "../options.js"; -import {mapX, mapY} from "./map.js"; - -export function normalizeX(basis, options) { - if (arguments.length === 1) ({basis, ...options} = basis); - return mapX(normalize(basis), options); -} - -export function normalizeY(basis, options) { - if (arguments.length === 1) ({basis, ...options} = basis); - return mapY(normalize(basis), options); -} - -export function normalize(basis) { - if (basis === undefined) return normalizeFirst; - if (typeof basis === "function") return normalizeBasis((I, S) => basis(take(S, I))); - if (/^p\d{2}$/i.test(basis)) return normalizeAccessor(percentile(basis)); - switch (`${basis}`.toLowerCase()) { - case "deviation": - return normalizeDeviation; - case "first": - return normalizeFirst; - case "last": - return normalizeLast; - case "max": - return normalizeMax; - case "mean": - return normalizeMean; - case "median": - return normalizeMedian; - case "min": - return normalizeMin; - case "sum": - return normalizeSum; - case "extent": - return normalizeExtent; - } - throw new Error(`invalid basis: ${basis}`); -} - -function normalizeBasis(basis) { - return { - map(I, S, T) { - const b = +basis(I, S); - for (const i of I) { - T[i] = S[i] === null ? NaN : S[i] / b; - } - } - }; -} - -function normalizeAccessor(f) { - return normalizeBasis((I, S) => f(I, (i) => S[i])); -} - -const normalizeExtent = { - map(I, S, T) { - const [s1, s2] = extent(I, (i) => S[i]), - d = s2 - s1; - for (const i of I) { - T[i] = S[i] === null ? NaN : (S[i] - s1) / d; - } - } -}; - -const normalizeFirst = normalizeBasis((I, S) => { - for (let i = 0; i < I.length; ++i) { - const s = S[I[i]]; - if (defined(s)) return s; - } -}); - -const normalizeLast = normalizeBasis((I, S) => { - for (let i = I.length - 1; i >= 0; --i) { - const s = S[I[i]]; - if (defined(s)) return s; - } -}); - -const normalizeDeviation = { - map(I, S, T) { - const m = mean(I, (i) => S[i]); - const d = deviation(I, (i) => S[i]); - for (const i of I) { - T[i] = S[i] === null ? NaN : d ? (S[i] - m) / d : 0; - } - } -}; - -const normalizeMax = normalizeAccessor(max); -const normalizeMean = normalizeAccessor(mean); -const normalizeMedian = normalizeAccessor(median); -const normalizeMin = normalizeAccessor(min); -const normalizeSum = normalizeAccessor(sum); diff --git a/src/transforms/normalize.ts b/src/transforms/normalize.ts new file mode 100644 index 0000000000..4037137994 --- /dev/null +++ b/src/transforms/normalize.ts @@ -0,0 +1,100 @@ +import type {Basis, MarkOptions} from "../api.js"; +import type {Datum, index, Series, Value, ValueArray} from "../data.js"; +import type {pXX} from "../options.js"; + +import {extent, deviation, max, mean, median, min, sum} from "d3"; +import {defined} from "../defined.js"; +import {percentile, take} from "../options.js"; +import {mapX, mapY} from "./map.js"; + +export function normalizeX(basis: Basis | MarkOptions | undefined, options?: MarkOptions) { + if (arguments.length === 1) ({basis, ...options} = basis as MarkOptions); + return mapX(normalize(basis as Basis), options); +} + +export function normalizeY(basis: Basis | MarkOptions | undefined, options?: MarkOptions) { + if (arguments.length === 1) ({basis, ...options} = basis as MarkOptions); + return mapY(normalize(basis as Basis), options); +} + +export function normalize(basis: Basis) { + if (basis === undefined) return normalizeFirst; + if (typeof basis === "function") return normalizeBasis((I: Series, S: ValueArray) => basis(take(S, I))); + if (/^p\d{2}$/i.test(basis)) return normalizeAccessor(percentile(basis as pXX)); + switch (`${basis}`.toLowerCase()) { + case "deviation": + return normalizeDeviation; + case "first": + return normalizeFirst; + case "last": + return normalizeLast; + case "max": + return normalizeMax; + case "mean": + return normalizeMean; + case "median": + return normalizeMedian; + case "min": + return normalizeMin; + case "sum": + return normalizeSum; + case "extent": + return normalizeExtent; + } + throw new Error(`invalid basis: ${basis}`); +} + +function normalizeBasis(basis: (I: Series, S: ValueArray) => Value) { + return { + map(I: Series, S: ValueArray, T: ValueArray) { + const b = +(basis(I, S) as number); + for (const i of I) { + T[i] = S[i] === null ? NaN : (S[i] as number) / b; + } + } + }; +} + +function normalizeAccessor(f: (I: Series, b: (i: index) => Value) => Value) { + return normalizeBasis((I, S) => f(I, (i: index) => S[i])); +} + +const normalizeExtent = { + map(I: Series, S: ValueArray, T: ValueArray) { + const [s1, s2] = extent(I, (i) => S[i] as number), + d = (s2 as number) - (s1 as number); + for (const i of I) { + T[i] = S[i] === null ? NaN : ((S[i] as number) - (s1 as number)) / d; + } + } +}; + +const normalizeFirst = normalizeBasis((I: Series, S: ValueArray) => { + for (let i = 0; i < I.length; ++i) { + const s = S[I[i]]; + if (defined(s)) return s; + } +}); + +const normalizeLast = normalizeBasis((I: Series, S: ValueArray) => { + for (let i = I.length - 1; i >= 0; --i) { + const s = S[I[i]]; + if (defined(s)) return s; + } +}); + +const normalizeDeviation = { + map(I: Series, S: ValueArray, T: ValueArray) { + const m = mean(I, (i) => S[i] as number); + const d = deviation(I, (i) => S[i] as number); + for (const i of I) { + T[i] = S[i] === null ? NaN : d ? ((S[i] as number) - (m as number)) / d : 0; + } + } +}; + +const normalizeMax = normalizeAccessor(max); +const normalizeMean = normalizeAccessor(mean); +const normalizeMedian = normalizeAccessor(median); +const normalizeMin = normalizeAccessor(min); +const normalizeSum = normalizeAccessor(sum); diff --git a/src/transforms/select.js b/src/transforms/select.ts similarity index 54% rename from src/transforms/select.js rename to src/transforms/select.ts index 66c9ebd854..a47ba56f3b 100644 --- a/src/transforms/select.js +++ b/src/transforms/select.ts @@ -1,8 +1,15 @@ +import type {MarkOptions, Selector, SelectorFunction} from "../api.js"; +import type {DataArray, Datum, index, Series, ValueArray} from "../data.js"; + +type InstantiatedSelector = + | ((I: Series, X: ValueArray) => Iterable | Generator) + | ((I: Series) => Iterable | Generator); + import {greatest, group, least} from "d3"; import {maybeZ, valueof} from "../options.js"; import {basic} from "./basic.js"; -export function select(selector, options = {}) { +export function select(selector: Selector, options: MarkOptions = {}) { // If specified selector is a string or function, it’s a selector without an // input channel such as first or last. if (typeof selector === "string") { @@ -12,6 +19,7 @@ export function select(selector, options = {}) { case "last": return selectLast(options); } + throw new Error(`invalid selector: ${selector}`); } if (typeof selector === "function") { return selectChannel(null, selector, options); @@ -28,7 +36,7 @@ export function select(selector, options = {}) { return selectChannel(key, value, options); } -function maybeSelector(selector) { +function maybeSelector(selector: SelectorFunction): InstantiatedSelector { if (typeof selector === "function") return selector; switch (`${selector}`.toLowerCase()) { case "min": @@ -39,61 +47,68 @@ function maybeSelector(selector) { throw new Error(`unknown selector: ${selector}`); } -export function selectFirst(options) { +export function selectFirst(options: MarkOptions) { return selectChannel(null, selectorFirst, options); } -export function selectLast(options) { +export function selectLast(options: MarkOptions) { return selectChannel(null, selectorLast, options); } -export function selectMinX(options) { +export function selectMinX(options: MarkOptions) { return selectChannel("x", selectorMin, options); } -export function selectMinY(options) { +export function selectMinY(options: MarkOptions) { return selectChannel("y", selectorMin, options); } -export function selectMaxX(options) { +export function selectMaxX(options: MarkOptions) { return selectChannel("x", selectorMax, options); } -export function selectMaxY(options) { +export function selectMaxY(options: MarkOptions) { return selectChannel("y", selectorMax, options); } -function* selectorFirst(I) { +function* selectorFirst(I: Series) { yield I[0]; } -function* selectorLast(I) { +function* selectorLast(I: Series) { yield I[I.length - 1]; } -function* selectorMin(I, X) { +function* selectorMin(I: Series, X: ValueArray) { yield least(I, (i) => X[i]); } -function* selectorMax(I, X) { +function* selectorMax(I: Series, X: ValueArray) { yield greatest(I, (i) => X[i]); } -function selectChannel(v, selector, options) { - if (v != null) { - if (options[v] == null) throw new Error(`missing channel: ${v}`); - v = options[v]; +function selectChannel( + v0: string | null | undefined, + selector: InstantiatedSelector, + options: MarkOptions +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let v: any = v0; + if (v0 != null) { + if ((options[v0 as keyof MarkOptions] as ValueArray) == null) throw new Error(`missing channel: ${v}`); + v = options[v0 as keyof MarkOptions]; } const z = maybeZ(options); - return basic(options, (data, facets) => { + return basic(options, (data: DataArray, facets: Series[]) => { const Z = valueof(data, z); const V = valueof(data, v); const selectFacets = []; for (const facet of facets) { const selectFacet = []; for (const I of Z ? group(facet, (i) => Z[i]).values() : [facet]) { - for (const i of selector(I, V)) { - selectFacet.push(i); + // TODO: check if V can be null | undefined?? + for (const i of selector(I, V as ValueArray)) { + if (i !== undefined) selectFacet.push(i); } } selectFacets.push(selectFacet); diff --git a/src/transforms/stack.js b/src/transforms/stack.ts similarity index 58% rename from src/transforms/stack.js rename to src/transforms/stack.ts index aed8a04573..d9ffaded9e 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.ts @@ -1,57 +1,62 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type {MarkOptions, OffsetFunction, StackOrder} from "../api.js"; +import type {DataArray, Datum, index, Series, Value, ValueArray} from "../data.js"; +import type {GetColumn, Accessor} from "../options.js"; + import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3"; import {ascendingDefined} from "../defined.js"; import {field, column, maybeColumn, maybeZ, mid, range, valueof, maybeZero, one} from "../options.js"; import {basic} from "./basic.js"; -export function stackX(stackOptions = {}, options = {}) { +export function stackX(stackOptions: MarkOptions = {}, options: MarkOptions = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {y1, y = y1, x, ...rest} = options; // note: consumes x! - const [transform, Y, x1, x2] = stack(y, x, "x", stackOptions, rest); + const [transform, Y, x1, x2] = stack(y, x, "x", stackOptions, rest); return {...transform, y1, y: Y, x1, x2, x: mid(x1, x2)}; } -export function stackX1(stackOptions = {}, options = {}) { - if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); +export function stackX1(stackOptions: MarkOptions = {}, options: MarkOptions = {}) { + if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {y1, y = y1, x} = options; const [transform, Y, X] = stack(y, x, "x", stackOptions, options); return {...transform, y1, y: Y, x: X}; } -export function stackX2(stackOptions = {}, options = {}) { - if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); +export function stackX2(stackOptions: MarkOptions = {}, options: MarkOptions = {}) { + if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {y1, y = y1, x} = options; const [transform, Y, , X] = stack(y, x, "x", stackOptions, options); return {...transform, y1, y: Y, x: X}; } -export function stackY(stackOptions = {}, options = {}) { - if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); +export function stackY(stackOptions: MarkOptions = {}, options: MarkOptions = {}) { + if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {x1, x = x1, y, ...rest} = options; // note: consumes y! const [transform, X, y1, y2] = stack(x, y, "y", stackOptions, rest); return {...transform, x1, x: X, y1, y2, y: mid(y1, y2)}; } -export function stackY1(stackOptions = {}, options = {}) { - if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); +export function stackY1(stackOptions: MarkOptions = {}, options: MarkOptions = {}) { + if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {x1, x = x1, y} = options; const [transform, X, Y] = stack(x, y, "y", stackOptions, options); return {...transform, x1, x: X, y: Y}; } -export function stackY2(stackOptions = {}, options = {}) { +export function stackY2(stackOptions: MarkOptions = {}, options: MarkOptions = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {x1, x = x1, y} = options; const [transform, X, , Y] = stack(x, y, "y", stackOptions, options); return {...transform, x1, x: X, y: Y}; } -export function maybeStackX({x, x1, x2, ...options} = {}) { +export function maybeStackX({x, x1, x2, ...options}: MarkOptions = {}) { if (x1 === undefined && x2 === undefined) return stackX({x, ...options}); [x1, x2] = maybeZero(x, x1, x2); return {...options, x1, x2}; } -export function maybeStackY({y, y1, y2, ...options} = {}) { +export function maybeStackY({y, y1, y2, ...options}: MarkOptions = {}) { if (y1 === undefined && y2 === undefined) return stackY({y, ...options}); [y1, y2] = maybeZero(y, y1, y2); return {...options, y1, y2}; @@ -60,22 +65,29 @@ export function maybeStackY({y, y1, y2, ...options} = {}) { // The reverse option is ambiguous: it is both a stack option and a basic // transform. If only one options object is specified, we interpret it as a // stack option, and therefore must remove it from the propagated options. -function mergeOptions(options) { +function mergeOptions(options: MarkOptions) { const {offset, order, reverse, ...rest} = options; return [{offset, order, reverse}, rest]; } -function stack(x, y = one, ky, {offset, order, reverse}, options) { +function stack( + x: Accessor | number | undefined, + y: Accessor | number = one, + ky: string, + {offset: offset0, order: order0, reverse}: MarkOptions, + options: MarkOptions +): [MarkOptions, GetColumn | null | undefined, GetColumn, GetColumn] { + if (y === null) throw new Error(`null channel ${ky}`); const z = maybeZ(options); const [X, setX] = maybeColumn(x); const [Y1, setY1] = column(y); const [Y2, setY2] = column(y); - offset = maybeOffset(offset); - order = maybeOrder(order, offset, ky); + const offset = maybeOffset(offset0); + const order = maybeOrder(order0, offset, ky); return [ basic(options, (data, facets) => { - const X = x == null ? undefined : setX(valueof(data, x)); - const Y = valueof(data, y, Float64Array); + const X = x == null ? undefined : setX!(valueof(data, x)!); + const Y = valueof(data, y, Float64Array) as Float64Array; // TODO const Z = valueof(data, z); const O = order && order(data, X, Y, Z); const n = data.length; @@ -107,7 +119,7 @@ function stack(x, y = one, ky, {offset, order, reverse}, options) { ]; } -function maybeOffset(offset) { +function maybeOffset(offset: string | OffsetFunction | null | undefined) { if (offset == null) return; if (typeof offset === "function") return offset; switch (`${offset}`.toLowerCase()) { @@ -126,7 +138,7 @@ function maybeOffset(offset) { // Given a single stack, returns the minimum and maximum values from the given // Y2 column. Note that this relies on Y2 always being the outer column for // diverging values. -function extent(stack, Y2) { +function extent(stack: Series, Y2: Float64Array) { let min = 0, max = 0; for (const i of stack) { @@ -137,7 +149,7 @@ function extent(stack, Y2) { return [min, max]; } -function offsetExpand(facetstacks, Y1, Y2) { +function offsetExpand(facetstacks: Series[][], Y1: Float64Array, Y2: Float64Array) { for (const stacks of facetstacks) { for (const stack of stacks) { const [yn, yp] = extent(stack, Y2); @@ -150,7 +162,7 @@ function offsetExpand(facetstacks, Y1, Y2) { } } -function offsetCenter(facetstacks, Y1, Y2) { +function offsetCenter(facetstacks: Series[][], Y1: Float64Array, Y2: Float64Array) { for (const stacks of facetstacks) { for (const stack of stacks) { const [yn, yp] = extent(stack, Y2); @@ -165,15 +177,15 @@ function offsetCenter(facetstacks, Y1, Y2) { offsetCenterFacets(facetstacks, Y1, Y2); } -function offsetWiggle(facetstacks, Y1, Y2, Z) { +function offsetWiggle(facetstacks: Series[][], Y1: Float64Array, Y2: Float64Array, Z: ValueArray | null | undefined) { for (const stacks of facetstacks) { const prev = new InternMap(); let y = 0; for (const stack of stacks) { - let j = -1; - const Fi = stack.map((i) => Math.abs(Y2[i] - Y1[i])); - const Df = stack.map((i) => { - j = Z ? Z[i] : ++j; + let j: Value = -1; // type: j is a number of Z is null, otherwise it's the z value + const Fi = stack.map((i: index) => Math.abs(Y2[i] - Y1[i])); + const Df = stack.map((i: index) => { + j = Z ? Z[i] : ++(j as number); const value = Y2[i] - Y1[i]; const diff = prev.has(j) ? value - prev.get(j) : 0; prev.set(j, value); @@ -192,8 +204,8 @@ function offsetWiggle(facetstacks, Y1, Y2, Z) { offsetCenterFacets(facetstacks, Y1, Y2); } -function offsetZero(stacks, Y1, Y2) { - const m = min(stacks, (stack) => min(stack, (i) => Y1[i])); +function offsetZero(stacks: Series[], Y1: Float64Array, Y2: Float64Array) { + const m = min(stacks, (stack) => min(stack, (i) => Y1[i])) as number; for (const stack of stacks) { for (const i of stack) { Y1[i] -= m; @@ -202,12 +214,12 @@ function offsetZero(stacks, Y1, Y2) { } } -function offsetCenterFacets(facetstacks, Y1, Y2) { +function offsetCenterFacets(facetstacks: Series[][], Y1: Float64Array, Y2: Float64Array) { const n = facetstacks.length; if (n === 1) return; - const facets = facetstacks.map((stacks) => stacks.flat()); - const m = facets.map((I) => (min(I, (i) => Y1[i]) + max(I, (i) => Y2[i])) / 2); - const m0 = min(m); + const facets = facetstacks.map((stacks) => stacks.flat()) as Series[]; + const m = facets.map((I) => ((min(I, (i: index) => Y1[i]) as number) + (max(I, (i: index) => Y2[i]) as number)) / 2); + const m0 = min(m) as number; for (let j = 0; j < n; j++) { const p = m0 - m[j]; for (const i of facets[j]) { @@ -217,7 +229,20 @@ function offsetCenterFacets(facetstacks, Y1, Y2) { } } -function maybeOrder(order, offset, ky) { +function maybeOrder( + order: StackOrder | undefined, + offset: OffsetFunction | undefined, + ky: string +): + | ((data: DataArray) => ValueArray) + | ((data: DataArray, X: ValueArray | undefined, Y: Float64Array) => ValueArray) + | (( + data: DataArray, + X: ValueArray | undefined, + Y: Float64Array, + Z: ValueArray | null | undefined + ) => ValueArray | null | undefined) + | undefined { if (order === undefined && offset === offsetWiggle) return orderInsideOut; if (order == null) return; if (typeof order === "string") { @@ -242,17 +267,28 @@ function maybeOrder(order, offset, ky) { } // by value -function orderY(data, X, Y) { +function orderY(data: DataArray, X: ValueArray | undefined, Y: Float64Array) { return Y; } // by location -function orderZ(order, X, Y, Z) { +function orderZ( + data: DataArray, + X: ValueArray | undefined, + Y: Float64Array, + Z: ValueArray | null | undefined +) { return Z; } // by sum of value (a.k.a. “ascending”) -function orderSum(data, X, Y, Z) { +function orderSum( + data: DataArray, + X: ValueArray | undefined, + Y: Float64Array, + Z?: ValueArray | null +) { + if (Z == null) throw new Error(`missing channel: Z`); return orderZDomain( Z, groupSort( @@ -260,16 +296,23 @@ function orderSum(data, X, Y, Z) { (I) => sum(I, (i) => Y[i]), (i) => Z[i] ) - ); + ) as ValueArray; } // by x = argmax of value -function orderAppearance(data, X, Y, Z) { +function orderAppearance( + data: DataArray, + X: ValueArray | undefined, + Y: Float64Array, + Z?: ValueArray | null +) { + if (X === undefined) throw new Error(`cannot order by appearance without a base`); + if (Z == null) throw new Error(`cannot order by appearance without a Z channel`); return orderZDomain( Z, groupSort( range(data), - (I) => X[greatest(I, (i) => Y[i])], + (I) => X[greatest(I, (i) => Y[i]) as index], (i) => Z[i] ) ); @@ -277,11 +320,18 @@ function orderAppearance(data, X, Y, Z) { // by x = argmax of value, but rearranged inside-out by alternating series // according to the sign of a running divergence of sums -function orderInsideOut(data, X, Y, Z) { +function orderInsideOut( + data: DataArray, + X: ValueArray | undefined, + Y: Float64Array, + Z?: ValueArray | null +) { + if (X === undefined) throw new Error(`cannot order by appearance without a base`); + if (Z == null) throw new Error(`cannot order by appearance without a Z channel`); const I = range(data); const K = groupSort( I, - (I) => X[greatest(I, (i) => Y[i])], + (I) => X[greatest(I, (i) => Y[i]) as number], (i) => Z[i] ); const sums = rollup( @@ -294,33 +344,36 @@ function orderInsideOut(data, X, Y, Z) { let s = 0; for (const k of K) { if (s < 0) { - s += sums.get(k); + s += sums.get(k)!; Kp.push(k); } else { - s -= sums.get(k); + s -= sums.get(k)!; Kn.push(k); } } return orderZDomain(Z, Kn.reverse().concat(Kp)); } -function orderFunction(f) { - return (data) => valueof(data, f); +function orderFunction(f: (d: T) => Value) { + return (data: DataArray) => valueof(data, f); } -function orderGiven(domain) { - return (data, X, Y, Z) => orderZDomain(Z, domain); +function orderGiven(domain: Value[]) { + return (data: DataArray, X: ValueArray | undefined, Y: Float64Array, Z: ValueArray | null | undefined) => { + if (Z == null) throw new Error(`missing channel: Z`); + return orderZDomain(Z, domain); + }; } // Given an explicit ordering of distinct values in z, returns a parallel column // O that can be used with applyOrder to sort stacks. Note that this is a series // order: it will be consistent across stacks. -function orderZDomain(Z, domain) { - domain = new InternMap(domain.map((d, i) => [d, i])); - return Z.map((z) => domain.get(z)); +function orderZDomain(Z: ValueArray, domain0: Value[]) { + const domain = new InternMap(domain0.map((d, i) => [d, i])); + return Z.map((z) => domain.get(z)!); } -function applyOrder(stacks, O) { +function applyOrder(stacks: Series[], O: ValueArray) { for (const stack of stacks) { stack.sort((i, j) => ascendingDefined(O[i], O[j])); } diff --git a/src/transforms/window.js b/src/transforms/window.ts similarity index 54% rename from src/transforms/window.js rename to src/transforms/window.ts index a0300c8085..c2fb027a89 100644 --- a/src/transforms/window.js +++ b/src/transforms/window.ts @@ -1,31 +1,42 @@ -import {deviation, max, min, median, mode, variance} from "d3"; +import type {MarkOptions, WindowOptions} from "../api.js"; +import type {Datum, Value, index, NumericArray, Series, ValueArray} from "../data.js"; +import type {pXX} from "../options.js"; + +import {deviation, max, min, median, mode, quantile, variance} from "d3"; import {defined} from "../defined.js"; -import {percentile, take} from "../options.js"; +import {take} from "../options.js"; import {warn} from "../warnings.js"; import {mapX, mapY} from "./map.js"; -export function windowX(windowOptions = {}, options) { +export function windowX( + windowOptions: number | MarkOptions = {}, + options: number | MarkOptions +) { if (arguments.length === 1) options = windowOptions; - return mapX(window(windowOptions), options); + return mapX(window(windowOptions), options as MarkOptions); } -export function windowY(windowOptions = {}, options) { +export function windowY( + windowOptions: number | MarkOptions = {}, + options: number | MarkOptions +) { if (arguments.length === 1) options = windowOptions; - return mapY(window(windowOptions), options); + return mapY(window(windowOptions), options as MarkOptions); } -export function window(options = {}) { - if (typeof options === "number") options = {k: options}; +export function window(options: number | MarkOptions = {}) { + if (typeof options === "number") options = {k: options} as MarkOptions; + // eslint-disable-next-line prefer-const let {k, reduce, shift, anchor, strict} = options; if (anchor === undefined && shift !== undefined) { anchor = maybeShift(shift); warn(`Warning: the shift option is deprecated; please use anchor "${anchor}" instead.`); } - if (!((k = Math.floor(k)) > 0)) throw new Error(`invalid k: ${k}`); + if (!((k = Math.floor(k as number)) > 0)) throw new Error(`invalid k: ${k}`); return maybeReduce(reduce)(k, maybeAnchor(anchor, k), strict); } -function maybeAnchor(anchor = "middle", k) { +function maybeAnchor(anchor = "middle", k: number) { switch (`${anchor}`.toLowerCase()) { case "middle": return (k - 1) >> 1; @@ -37,7 +48,7 @@ function maybeAnchor(anchor = "middle", k) { throw new Error(`invalid anchor: ${anchor}`); } -function maybeShift(shift) { +function maybeShift(shift: string) { switch (`${shift}`.toLowerCase()) { case "centered": return "middle"; @@ -49,9 +60,9 @@ function maybeShift(shift) { throw new Error(`invalid shift: ${shift}`); } -function maybeReduce(reduce = "mean") { +function maybeReduce(reduce: WindowOptions["reduce"] = "mean") { if (typeof reduce === "string") { - if (/^p\d{2}$/i.test(reduce)) return reduceNumbers(percentile(reduce)); + if (/^p\d{2}$/i.test(reduce)) return reduceNumbers(percentile(reduce as pXX)); switch (reduce.toLowerCase()) { case "deviation": return reduceNumbers(deviation); @@ -64,7 +75,7 @@ function maybeReduce(reduce = "mean") { case "min": return reduceArray(min); case "mode": - return reduceArray(mode); + return reduceArray(mode as (X: ValueArray) => Value); // TODO: fix @types/d3 case "sum": return reduceSum; case "variance": @@ -83,58 +94,58 @@ function maybeReduce(reduce = "mean") { return reduceArray(reduce); } -function slice(I, i, j) { - return I.subarray ? I.subarray(i, j) : I.slice(i, j); +function slice(I: Series, i: index, j: index) { + return (I as Uint32Array).subarray ? (I as Uint32Array).subarray(i, j) : I.slice(i, j); } // Note that the subarray may include NaN in the non-strict case; we expect the // function f to handle that itself (e.g., by filtering as needed). The D3 // reducers (e.g., min, max, mean, median) do, and it’s faster to avoid // redundant filtering. -function reduceNumbers(f) { - return (k, s, strict) => +function reduceNumbers(f: (X: NumericArray) => Value) { + return (k: number, s: number, strict: boolean | undefined) => strict ? { - map(I, S, T) { - const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : S[i])); + map(I: Series, S: ValueArray, T: ValueArray) { + const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : (S[i] as number))); let nans = 0; for (let i = 0; i < k - 1; ++i) if (isNaN(C[i])) ++nans; for (let i = 0, n = I.length - k + 1; i < n; ++i) { if (isNaN(C[i + k - 1])) ++nans; - T[I[i + s]] = nans === 0 ? f(C.subarray(i, i + k)) : NaN; + T[I[i + s]] = nans === 0 ? (f(C.subarray(i, i + k)) as number) : NaN; if (isNaN(C[i])) --nans; } } } : { - map(I, S, T) { - const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : S[i])); + map(I: Series, S: ValueArray, T: ValueArray) { + const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : (S[i] as number))); for (let i = -s; i < 0; ++i) { - T[I[i + s]] = f(C.subarray(0, i + k)); + T[I[i + s]] = f(C.subarray(0, i + k)) as number; } for (let i = 0, n = I.length - s; i < n; ++i) { - T[I[i + s]] = f(C.subarray(i, i + k)); + T[I[i + s]] = f(C.subarray(i, i + k)) as number; } } }; } -function reduceArray(f) { - return (k, s, strict) => +function reduceArray(f: (X: ValueArray) => Value) { + return (k: number, s: number, strict: boolean | undefined) => strict ? { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { let count = 0; - for (let i = 0; i < k - 1; ++i) count += defined(S[I[i]]); + for (let i = 0; i < k - 1; ++i) count += defined(S[I[i]]) as unknown as number; // TODO! doh! for (let i = 0, n = I.length - k + 1; i < n; ++i) { - count += defined(S[I[i + k - 1]]); + count += defined(S[I[i + k - 1]]) as unknown as number; if (count === k) T[I[i + s]] = f(take(S, slice(I, i, i + k))); - count -= defined(S[I[i]]); + count -= defined(S[I[i]]) as unknown as number; } } } : { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = -s; i < 0; ++i) { T[I[i + s]] = f(take(S, slice(I, 0, i + k))); } @@ -145,156 +156,156 @@ function reduceArray(f) { }; } -function reduceSum(k, s, strict) { +function reduceSum(k: number, s: number, strict: boolean | undefined) { return strict ? { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { let nans = 0; let sum = 0; for (let i = 0; i < k - 1; ++i) { const v = S[I[i]]; - if (v === null || isNaN(v)) ++nans; - else sum += +v; + if (v === null || isNaN(v as number)) ++nans; + else sum += +(v as number); } for (let i = 0, n = I.length - k + 1; i < n; ++i) { const a = S[I[i]]; const b = S[I[i + k - 1]]; - if (b === null || isNaN(b)) ++nans; - else sum += +b; + if (b === null || isNaN(b as number)) ++nans; + else sum += +(b as number); T[I[i + s]] = nans === 0 ? sum : NaN; - if (a === null || isNaN(a)) --nans; - else sum -= +a; + if (a === null || isNaN(a as number)) --nans; + else sum -= +(a as number); } } } : { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { let sum = 0; const n = I.length; for (let i = 0, j = Math.min(n, k - s - 1); i < j; ++i) { - sum += +S[I[i]] || 0; + sum += +(S[I[i]] as number) || 0; } for (let i = -s, j = n - s; i < j; ++i) { - sum += +S[I[i + k - 1]] || 0; + sum += +(S[I[i + k - 1]] as number) || 0; T[I[i + s]] = sum; - sum -= +S[I[i]] || 0; + sum -= +(S[I[i]] as number) || 0; } } }; } -function reduceMean(k, s, strict) { +function reduceMean(k: number, s: number, strict: boolean | undefined) { if (strict) { const sum = reduceSum(k, s, strict); return { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { sum.map(I, S, T); for (let i = 0, n = I.length - k + 1; i < n; ++i) { - T[I[i + s]] /= k; + (T[I[i + s]] as number) /= k; } } }; } else { return { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { let sum = 0; let count = 0; const n = I.length; for (let i = 0, j = Math.min(n, k - s - 1); i < j; ++i) { let v = S[I[i]]; - if (v !== null && !isNaN((v = +v))) (sum += v), ++count; + if (v !== null && !isNaN((v = +(v as number)))) (sum += v), ++count; } for (let i = -s, j = n - s; i < j; ++i) { let a = S[I[i + k - 1]]; let b = S[I[i]]; - if (a !== null && !isNaN((a = +a))) (sum += a), ++count; + if (a !== null && !isNaN((a = +(a as number)))) (sum += a), ++count; T[I[i + s]] = sum / count; - if (b !== null && !isNaN((b = +b))) (sum -= b), --count; + if (b !== null && !isNaN((b = +(b as number)))) (sum -= b), --count; } } }; } } -function firstDefined(S, I, i, k) { +function firstDefined(S: ValueArray, I: Series, i: number, k: number) { for (let j = i + k; i < j; ++i) { const v = S[I[i]]; if (defined(v)) return v; } } -function lastDefined(S, I, i, k) { +function lastDefined(S: ValueArray, I: Series, i: number, k: number) { for (let j = i + k - 1; j >= i; --j) { const v = S[I[j]]; if (defined(v)) return v; } } -function firstNumber(S, I, i, k) { +function firstNumber(S: ValueArray, I: Series, i: number, k: number) { for (let j = i + k; i < j; ++i) { let v = S[I[i]]; - if (v !== null && !isNaN((v = +v))) return v; + if (v !== null && !isNaN((v = +(v as number)))) return v; } } -function lastNumber(S, I, i, k) { +function lastNumber(S: ValueArray, I: Series, i: number, k: number) { for (let j = i + k - 1; j >= i; --j) { let v = S[I[j]]; - if (v !== null && !isNaN((v = +v))) return v; + if (v !== null && !isNaN((v = +(v as number)))) return v; } } -function reduceDifference(k, s, strict) { +function reduceDifference(k: number, s: number, strict: boolean | undefined) { return strict ? { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = 0, n = I.length - k; i < n; ++i) { const a = S[I[i]]; const b = S[I[i + k - 1]]; - T[I[i + s]] = a === null || b === null ? NaN : b - a; + T[I[i + s]] = a === null || b === null ? NaN : (b as number) - (a as number); } } } : { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = -s, n = I.length - k + s + 1; i < n; ++i) { - T[I[i + s]] = lastNumber(S, I, i, k) - firstNumber(S, I, i, k); + T[I[i + s]] = (lastNumber(S, I, i, k) as number) - (firstNumber(S, I, i, k) as number); } } }; } -function reduceRatio(k, s, strict) { +function reduceRatio(k: number, s: number, strict: boolean | undefined) { return strict ? { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = 0, n = I.length - k; i < n; ++i) { const a = S[I[i]]; const b = S[I[i + k - 1]]; - T[I[i + s]] = a === null || b === null ? NaN : b / a; + T[I[i + s]] = a === null || b === null ? NaN : (b as number) / (a as number); } } } : { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = -s, n = I.length - k + s + 1; i < n; ++i) { - T[I[i + s]] = lastNumber(S, I, i, k) / firstNumber(S, I, i, k); + T[I[i + s]] = (lastNumber(S, I, i, k) as number) / (firstNumber(S, I, i, k) as number); } } }; } -function reduceFirst(k, s, strict) { +function reduceFirst(k: number, s: number, strict: boolean | undefined) { return strict ? { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = 0, n = I.length - k; i < n; ++i) { T[I[i + s]] = S[I[i]]; } } } : { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = -s, n = I.length - k + s + 1; i < n; ++i) { T[I[i + s]] = firstDefined(S, I, i, k); } @@ -302,20 +313,26 @@ function reduceFirst(k, s, strict) { }; } -function reduceLast(k, s, strict) { +function reduceLast(k: number, s: number, strict: boolean | undefined) { return strict ? { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = 0, n = I.length - k; i < n; ++i) { T[I[i + s]] = S[I[i + k - 1]]; } } } : { - map(I, S, T) { + map(I: Series, S: ValueArray, T: ValueArray) { for (let i = -s, n = I.length - k + s + 1; i < n; ++i) { T[I[i + s]] = lastDefined(S, I, i, k); } } }; } + +// takes an array of values +function percentile(reduce: pXX) { + const p = +`${reduce}`.slice(1) / 100; + return (X: NumericArray) => quantile(X, p); +} diff --git a/test/array/arrayify-test.ts b/test/array/arrayify-test.ts new file mode 100644 index 0000000000..8646e6689f --- /dev/null +++ b/test/array/arrayify-test.ts @@ -0,0 +1,31 @@ +import assert from "assert"; +import {arrayify} from "../../src/options.js"; + +it("arrayify null, undefined", () => { + for (const a of [null, undefined]) { + assert.strictEqual(arrayify(a), a); + assert.strictEqual(arrayify(a, Float32Array), a); + assert.strictEqual(arrayify(a, Float64Array), a); + assert.strictEqual(arrayify(a, Array), a); + } +}); + +it("arrayify typed arrays", () => { + const a = new Uint8ClampedArray(10); + assert.strictEqual(arrayify(a), a); + assert.strictEqual("" + arrayify(a, Float64Array), "" + a); + assert.notStrictEqual(arrayify(a, Float64Array), a); + assert.strictEqual(arrayify(a, Float64Array)[Symbol.toStringTag], "Float64Array"); + assert.strictEqual("" + arrayify(a, Array), "" + a); + assert.notStrictEqual(arrayify(a, Array), a); + assert.strictEqual(arrayify(a, Array)[Symbol.toStringTag], undefined); +}); + +it("arrayify arrays", () => { + const a = [1, "test", 1.5]; + assert.strictEqual(arrayify(a), a); + assert.strictEqual(arrayify(a, undefined), a); + assert.strictEqual(arrayify(a, Array), a); + assert.deepStrictEqual("" + arrayify(a, Float64Array), "" + [1, NaN, 1.5]); + assert.deepStrictEqual("" + arrayify(a, Uint16Array), "" + [1, 0, 1]); +}); diff --git a/test/array/valueof-test.ts b/test/array/valueof-test.ts new file mode 100644 index 0000000000..7e11ece94e --- /dev/null +++ b/test/array/valueof-test.ts @@ -0,0 +1,55 @@ +import assert from "assert"; +import {valueof} from "../../src/options.js"; + +it("valueof reads arrays", () => { + assert.deepStrictEqual( + valueof([1, 2, 3], (d: unknown) => d as number), + [1, 2, 3] + ); + assert.deepStrictEqual( + valueof([1, 2, 3], (d: unknown) => `${d}`, Array), + ["1", "2", "3"] + ); + assert.deepStrictEqual( + valueof([1, 2, 3], (d: unknown) => d as number, Float64Array), + Float64Array.of(1, 2, 3) + ); + + // data can be functions or other things + assert.deepStrictEqual( + valueof([(d: number) => d, new Promise(() => {})], (d: unknown) => `(${d})`), + ["(d=>d)", "([object Promise])"] + ); + + // data can be nullish and generated by the transform method + assert.deepStrictEqual(valueof(undefined, {transform: () => [1, "text"]}), [1, "text"]); + assert.deepStrictEqual(valueof(null, {transform: () => [1, "text"]}, Float32Array), Float32Array.of(1, NaN)); + assert.deepStrictEqual(valueof(null, {transform: () => new Float64Array(2)}, Array), [0, 0]); + + // ts type tests + valueof([1, 2, 3], (d: unknown) => d as number, Float32Array); + valueof(["1", 2, 3], (d: unknown) => d as string | number); + valueof(["1", 2, 3], (d: unknown) => d as string | number, Array); + valueof(["1", 2, 3], (d: unknown) => d as string | number, Float64Array); + valueof(["1", 2, 3], (d: unknown) => +(d as number), Float32Array); + valueof(new Set(["1", 2, 3]), (d: unknown) => +(d as number), Float32Array); +}); + +it("valueof does not crash on non iterable values with an accessor", () => { + for (const n of [null, undefined]) + assert.strictEqual( + valueof(n, () => 1), + n + ); +}); + +/* + +// field names are inferred +valueof([{a: 1}, {b: 2}], "a"); +valueof([{a: 1}, {b: 2}], "b"); + +// TODO: test for ts failure: +valueof([{a: 1}, {b: 2}], "c"); + +*/