diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000000..3b667ff734 --- /dev/null +++ b/src/common.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* + * API + */ + +/** + * Aggregation options for the group transform: + * * a string describing an aggregation (first, min, sum, count…) + * * a function - passed the array of values for each group + * * 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" | PXX | "deviation" | "variance" | ReduceFunction | Reduce1; +export type Reduce1 = { + label?: string; + reduce: (I: IndexArray, X: any, context?: any, extent?: any) => any; + scope?: "data" | "facet" +}; // TODO: rename to ReduceMethod + ReduceObject? +type ReduceFunction = ((data?: ArrayLike, extent?: any) => any); + +/** + * Facets expressed as an array of arrays of indices + */ +export type MaybeFacetArray = IndexArray[] | undefined; + +/** + * Array of indices into the data + */ + export type IndexArray = number[] | Uint32Array; + + +/** + * The inset option is a number + */ +export type InsetOption = number | undefined; + +/** + * Plot.column() + * @link https://github.com/observablehq/plot/blob/main/README.md#plotcolumnsource + */ +export type Column = [column, setColumn]; +export type column = {transform: () => any[]; label?: string} +export type setColumn = (v: Array) => Array; + + +/** + * 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 MapMethod = "cumsum" | "rank" | "quantile" | ((S: Channel) => Channel) | {map: (I: IndexArray, S: Channel, T: any[]) => any}; + +/* + * COMMON + */ + + +/* + * UNSORTED + */ + +export type nullish = null | undefined; +export type DataSource = Iterable | ArrayLike; +export type UserOption = unknown; // TODO: remove this type by checking which options are allowed in each case +export type booleanOption = boolean | nullish; +export type numberOption = number | nullish; +export type stringOption = number | any[] | string | nullish; +export type TextChannel = string[]; +export type NumberChannel = number[] | Float32Array | Float64Array; +export type Channel = TextChannel | NumberChannel | any[]; +export type ConstantOrFieldOption = string | IAccessor | number | Channel | Date | ITransform | nullish; +export type Comparator = (a: any, b: any) => number; + +/** + * Definition for both transform and initializer functions. + * TODO: clarify the difference (when facets are returned or not, in the case of an initializer) + */ +export type TransformFunction = (this: IMark, data: any, facets: MaybeFacetArray, channels?: any, scales ?: any, dimensions?: IDimensions) => {data?: any, facets?: IndexArray[], channels?: any}; + + +export type OutputOptions = Partial<{[P in FieldOptionsKey]: AggregationMethod}> & { + data?: any; + reverse?: boolean; +} + + + +export interface FieldOptions { + x?: ConstantOrFieldOption; + x1?: ConstantOrFieldOption; + x2?: ConstantOrFieldOption; + y?: ConstantOrFieldOption; + y1?: ConstantOrFieldOption; + y2?: ConstantOrFieldOption; + z?: ConstantOrFieldOption; + fill?: ConstantOrFieldOption; + stroke?: ConstantOrFieldOption; + title?: ConstantOrFieldOption; + href?: ConstantOrFieldOption; + filter?: ConstantOrFieldOption; + sort?: ConstantOrFieldOption; +} + +export interface MarkOptionsDefined extends FieldOptions { + transform?: TransformFunction | null; + initializer?: TransformFunction | null; + reverse?: boolean; +} + +export type FieldOptionsKey = keyof FieldOptions; +export type MarkOptions = MarkOptionsDefined | undefined; +export type ArrayType = ArrayConstructor | Float32ArrayConstructor | Float64ArrayConstructor; +export type IAccessor = (d: any, i: number, data?: ArrayLike) => any; +export type booleanish = boolean | undefined; +export type ITransform = {transform: (data: DataSource) => DataSource}; + +/** + * The document context, used to create new DOM elements. + * @link https://github.com/observablehq/plot/blob/main/README.md#layout-options + */ +export interface IContext { + document: Document; +} + +/** + * A restrictive definition of D3 selections + */ +export interface ISelection { + append: (name: string) => ISelection; + attr: (name: string, value: any) => ISelection; + call: (callback: (selection: ISelection, ...args: any[]) => void, ...args: any[]) => ISelection; + each: (callback: (d: any) => void) => ISelection; + filter: (filter: (d: any, i: number) => boolean) => ISelection; + property: (name: string, value: any) => ISelection; + style: (name: string, value: any) => ISelection; + text: (value: any) => ISelection; + [Symbol.iterator]: () => IterableIterator; +} + +/** + * A restrictive definition of D3 scales + */ +export interface IScale { + bandwidth?: () => number; +} + +/** + * An object of style definitions to apply to DOM elements + */ +export type IStyleObject = Record; + +/** + * A mark + * @link https://github.com/observablehq/plot/blob/main/README.md#mark-options + */ +export interface IMark { + z?: UserOption; // copy the user option for error messages + clip?: "frame"; + dx: number; + dy: number; + marker?: MaybeMarkerFunction; + markerStart?: MaybeMarkerFunction; + markerMid?: MaybeMarkerFunction; + markerEnd?: MaybeMarkerFunction; + stroke?: string | nullish; + // common styles + fill?: string | nullish; + fillOpacity?: number | nullish; + strokeWidth?: number | nullish; + strokeOpacity?: number | nullish; + strokeLinejoin?: string | nullish; + strokeLinecap?: string | nullish; + strokeMiterlimit?: number | nullish; + strokeDasharray?: string | nullish; + strokeDashoffset?: string | nullish; + target?: string | nullish; + ariaLabel?: string | nullish; + ariaDescription?: string | nullish; + ariaHidden?: string | nullish; // "true" | "false" | undefined + opacity?: number | nullish; + mixBlendMode?: string | nullish; + paintOrder?: string | nullish; + pointerEvents?: string | nullish; + shapeRendering?: string | nullish; + // other styles, some of which are not supported by all marks + frameAnchor?: string; +} + +/** + * A key: value record of channels values + */ +export type ChannelObject = Record; + +/** + * The dimensions of the plot or the facet + */ +export interface IDimensions { + width: number; + height: number; + marginLeft: number; + marginRight: number; + marginTop: number; + marginBottom: number; +} + +/* + * Reducers + */ + +type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +/** + * Percentile reducer, for transforms such as bin, group, map and window + * @link https://github.com/observablehq/plot/blob/main/README.md#bin + */ +export type PXX = `p${Digit}${Digit}`; + +/** + * A marker defines a graphic drawn on vertices of a line or a link mark + * @link https://github.com/observablehq/plot/blob/main/README.md#markers + */ +export type MarkerOption = string | boolean | nullish; +export type MarkerFunction = (color: any, context: any) => Element; +export type MaybeMarkerFunction = MarkerFunction | null; diff --git a/src/context.js b/src/context.js deleted file mode 100644 index 7603225f78..0000000000 --- a/src/context.js +++ /dev/null @@ -1,9 +0,0 @@ -import {creator, select} from "d3"; - -export function Context({document = window.document} = {}) { - return {document}; -} - -export function create(name, {document}) { - return select(creator(name).call(document.documentElement)); -} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000000..b327604910 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,10 @@ +import type {IContext} from "./common.js"; +import {creator, select} from "d3"; + +export function Context({document = window.document} = {}): IContext { + return {document}; +} + +export function create(name: string, {document}: IContext) { + return select(creator(name).call(document.documentElement)); +} diff --git a/src/format.ts b/src/format.ts index 56c0f6658d..6fcf29197d 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type {nullish} from "./common.js"; + import {format as isoFormat} from "isoformat"; import {string} from "./options.js"; import {memoize1} from "./memoize.js"; @@ -26,7 +28,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 | number | nullish { 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 64% rename from src/marks/marker.js rename to src/marks/marker.ts index 3b79cc5c5c..769c6a3acd 100644 --- a/src/marks/marker.js +++ b/src/marks/marker.ts @@ -1,17 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {ISelection, IMark, IContext, MarkerOption, MarkerFunction, MaybeMarkerFunction, nullish, IndexArray} from "../common.js"; + import {create} from "../context.js"; -export function markers(mark, { +export function markers(mark: IMark, { marker, markerStart = marker, markerMid = marker, markerEnd = marker +}: { + marker?: MarkerOption, + markerStart?: MarkerOption, + markerMid?: MarkerOption, + markerEnd?: MarkerOption } = {}) { mark.markerStart = maybeMarker(markerStart); mark.markerMid = maybeMarker(markerMid); mark.markerEnd = maybeMarker(markerEnd); } -function maybeMarker(marker) { +function maybeMarker(marker: MarkerOption): MaybeMarkerFunction { if (marker == null || marker === false) return null; if (marker === true) return markerCircleFill; if (typeof marker === "function") return marker; @@ -25,7 +33,7 @@ function maybeMarker(marker) { throw new Error(`invalid marker: ${marker}`); } -function markerArrow(color, context) { +function markerArrow(color: string, context: IContext) { return create("svg:marker", context) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) @@ -37,10 +45,10 @@ 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 Element; } -function markerDot(color, context) { +function markerDot(color: string, context: IContext) { return create("svg:marker", context) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) @@ -48,10 +56,10 @@ function markerDot(color, context) { .attr("fill", color) .attr("stroke", "none") .call(marker => marker.append("circle").attr("r", 2.5)) - .node(); + .node() as Element; } -function markerCircleFill(color, context) { +function markerCircleFill(color: string, context: IContext) { return create("svg:marker", context) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) @@ -60,10 +68,10 @@ function markerCircleFill(color, context) { .attr("stroke", "white") .attr("stroke-width", 1.5) .call(marker => marker.append("circle").attr("r", 3)) - .node(); + .node() as Element; } -function markerCircleStroke(color, context) { +function markerCircleStroke(color: string, context: IContext) { return create("svg:marker", context) .attr("viewBox", "-5 -5 10 10") .attr("markerWidth", 6.67) @@ -72,31 +80,40 @@ function markerCircleStroke(color, context) { .attr("stroke", color) .attr("stroke-width", 1.5) .call(marker => marker.append("circle").attr("r", 3)) - .node(); + .node() as Element; } let nextMarkerId = 0; -export function applyMarkers(path, mark, {stroke: S} = {}) { - return applyMarkersColor(path, mark, S && (i => S[i])); +export function applyMarkers(path: ISelection, mark: IMark, {stroke: S}: {stroke?: string[]} = {}) { + return applyMarkersColor(path, mark, S && ((i: number) => S[i])); } -export function applyGroupedMarkers(path, mark, {stroke: S} = {}) { - return applyMarkersColor(path, mark, S && (([i]) => S[i])); +export function applyGroupedMarkers(path: ISelection, mark: IMark, {stroke: S}: {stroke?: string[]} = {}) { + return applyMarkersColor(path, mark, S && (([i]: IndexArray) => S[i])); } -function applyMarkersColor(path, {markerStart, markerMid, markerEnd, stroke}, strokeof = () => stroke) { +function applyMarkersColor( + path: ISelection, + { + markerStart, + markerMid, + markerEnd, + stroke + }: IMark, + strokeof: ((i: any) => string | nullish) = (() => stroke) // any is really number or number[] +) { const iriByMarkerColor = new Map(); - function applyMarker(marker) { - return function(i) { + function applyMarker(marker: MarkerFunction) { + return function(this: Element, i: number | [number]) { 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); + const node = (this.parentNode as Element).insertBefore(marker(color, context), this) as Element; const id = `plot-marker-${++nextMarkerId}`; node.setAttribute("id", id); iriByColor.set(color, iri = `url(#${id})`); diff --git a/src/options.js b/src/options.ts similarity index 65% rename from src/options.js rename to src/options.ts index 8f25ecaaeb..1e965694de 100644 --- a/src/options.js +++ b/src/options.ts @@ -1,3 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + PXX, + DataSource, + UserOption, + ConstantOrFieldOption, + Column, + MarkOptionsDefined, + FieldOptionsKey, + MarkOptions, + ArrayType, + IAccessor, + booleanish, + ITransform, + stringOption, + IndexArray, + nullish +} from "./common.js"; + + import {parse as isoParse} from "isoformat"; import {color, descending, quantile} from "d3"; @@ -6,33 +26,36 @@ 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}; +/** + * @link https://github.com/observablehq/plot/blob/main/README.md#plotvalueofdata-value-type + */ +export function valueof(data: DataSource | nullish, value: ConstantOrFieldOption, arrayType?: ArrayType) { + return data == null ? data + : typeof value === "string" ? map(data, field(value as string), arrayType) + : typeof value === "function" ? map(data, value as IAccessor, arrayType) + : typeof value === "number" || value instanceof Date || typeof value === "boolean" ? map(data, constant(value), arrayType) + : value && typeof (value as ITransform).transform === "function" ? arrayify((value as ITransform).transform(data), arrayType) + : arrayify(value as ConstantOrFieldOption & Iterable, arrayType); // preserve undefined type +} + +export const field = (name: string) => (d: any) => d[name]; +export const indexOf = (d: any, i: number) => i; +export const identity = {transform: (d: any) => 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; +export const string = (x: any) => x == null ? x as nullish : `${x}`; +export const number = (x: any) => x == null ? x as nullish : +x; +export const boolean = (x: any) => x == null ? x : !!x; +export const first = (x: any[]) => x ? x[0] : undefined; +export const second = (x: any[]) => x ? x[1] : undefined; +export const constant = (x: any) => () => 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) { +export function percentile(reduce: PXX) { const p = +`${reduce}`.slice(1) / 100; - return (I, f) => quantile(I, p, f); + return (I: IndexArray, f: (i: number) => number) => quantile(I, p, f); } // Some channels may allow a string constant to be specified; to differentiate @@ -41,28 +64,28 @@ export function percentile(reduce) { // 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) { +export function maybeColorChannel(value: ConstantOrFieldOption, defaultValue?: string): [any, string | undefined] { if (value === undefined) value = defaultValue; return value === null ? [undefined, "none"] - : isColor(value) ? [undefined, value] + : isColor(value) ? [undefined, value as string] : [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) { +export function maybeNumberChannel(value: number | null | undefined, defaultValue?: number) { 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) { +export function maybeKeyword(input: string | null | undefined, name: string, allowed: string[]) { 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) { +export function keyword(input: string | null | undefined, name: string, allowed: string[]) { const i = `${input}`.toLowerCase(); if (!allowed.includes(i)) throw new Error(`invalid ${name}: ${input}`); return i; @@ -72,30 +95,30 @@ export function keyword(input, name, allowed) { // 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) { +export function arrayify(data: DataSource | nullish, type?: ArrayType) { return data == null ? data : (type === undefined - ? (data instanceof Array || data instanceof TypedArray) ? data : Array.from(data) - : (data instanceof type ? data : type.from(data))); + ? (data instanceof Array || data instanceof TypedArray) ? data as any[] : Array.from(data) + : (data instanceof type ? data : (type as ArrayConstructor).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); +export function map(values: DataSource, f: IAccessor, type: ArrayType = Array) { + return values instanceof type ? values.map(f) : (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, type = Array) { +export function slice(values: DataSource, type = Array) { return values instanceof type ? values.slice() : type.from(values); } -export function isTypedArray(values) { +export function isTypedArray(values: any) { return values instanceof TypedArray; } // Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. -export function isObject(option) { +export function isObject(option: any): boolean { return option?.toString === objectToString; } @@ -104,24 +127,24 @@ export function isObject(option) { // 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) { +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) { +export function isOptions(option: any) { 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) { +export function isDomainSort(sort: any) { 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) { +export function maybeZero(x: UserOption, x1: UserOption, x2: UserOption, x3: UserOption = identity) { if (x1 === undefined && x2 === undefined) { // {x} or {} x1 = 0, x2 = x === undefined ? x3 : x; } else if (x1 === undefined) { // {x, x2} or {x2} @@ -133,20 +156,20 @@ export function maybeZero(x, x1, x2, x3 = identity) { } // For marks that have x and y channels (e.g., cell, dot, line, text). -export function maybeTuple(x, y) { +export function maybeTuple(x: ConstantOrFieldOption, y: ConstantOrFieldOption): [ConstantOrFieldOption, ConstantOrFieldOption] { 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} = {}) { +export function maybeZ({z, fill, stroke}: MarkOptions = {}) { 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) { +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; @@ -154,21 +177,21 @@ export function range(data) { } // Returns a filtered range of data given the test function. -export function where(data, test) { +export function where(data: ArrayLike, test: IAccessor) { return range(data).filter(i => test(data[i], i, data)); } // Returns an array [values[index[0]], values[index[1]], …]. -export function take(values, index) { +export function take(values: ArrayLike, index: IndexArray) { return map(index, i => values[i]); } // Based on InternMap (d3.group). -export function keyof(value) { +export function keyof(value: any) { return value !== null && typeof value === "object" ? value.valueOf() : value; } -export function maybeInput(key, options) { +export function maybeInput(key: FieldOptionsKey, options: MarkOptionsDefined) { if (options[key] !== undefined) return options[key]; switch (key) { case "x1": case "x2": key = "x"; break; @@ -180,8 +203,8 @@ export function maybeInput(key, options) { // 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; +export function column(source: UserOption): Column { + let value: any[]; return [ { transform: () => value, @@ -192,11 +215,11 @@ export function column(source) { } // Like column, but allows the source to be null. -export function maybeColumn(source) { +export function maybeColumn(source: UserOption) { return source == null ? [source] : column(source); } -export function labelof(value, defaultValue) { +export function labelof(value: any, defaultValue?: string) { return typeof value === "string" ? value : value && value.label !== undefined ? value.label : defaultValue; @@ -206,11 +229,11 @@ export function labelof(value, defaultValue) { // 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) { +export function mid(x1: Column[0], x2: Column[0]) { return { - transform(data) { - const X1 = x1.transform(data); - const X2 = x2.transform(data); + transform() { + const X1 = x1.transform(); // there was a type error here!! + const X2 = x2.transform(); return isTemporal(X1) || isTemporal(X2) ? map(X1, (_, i) => new Date((+X1[i] + +X2[i]) / 2)) : map(X1, (_, i) => (+X1[i] + +X2[i]) / 2, Float64Array); @@ -220,32 +243,32 @@ export function mid(x1, x2) { } // This distinguishes between per-dimension options and a standalone value. -export function maybeValue(value) { +export function maybeValue(value: any): undefined | {channel?: string, 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) { +export function numberChannel(source: any) { return source == null ? null : { - transform: data => valueof(data, source, Float64Array), + transform: (data: DataSource) => valueof(data, source, Float64Array), label: labelof(source) }; } -export function isIterable(value) { +export function isIterable(value: any): boolean { return value && typeof value[Symbol.iterator] === "function"; } -export function isTextual(values) { +export function isTextual(values: any[]): booleanish { for (const value of values) { if (value == null) continue; return typeof value !== "object" || value instanceof Date; } } -export function isOrdinal(values) { +export function isOrdinal(values: any[]): booleanish { for (const value of values) { if (value == null) continue; const type = typeof value; @@ -253,7 +276,7 @@ export function isOrdinal(values) { } } -export function isTemporal(values) { +export function isTemporal(values: any[]): booleanish { for (const value of values) { if (value == null) continue; return value instanceof Date; @@ -264,30 +287,30 @@ export function isTemporal(values) { // 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) { +export function isTemporalString(values: any[]): booleanish { for (const value of values) { if (value == null) continue; - return typeof value === "string" && isNaN(value) && isoParse(value); + return typeof value === "string" && isNaN(value as unknown as number) && !!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) { +export function isNumericString(values: any[]): booleanish { for (const value of values) { if (value == null || value === "") continue; - return typeof value === "string" && !isNaN(value); + return typeof value === "string" && !isNaN(value as unknown as number); } } -export function isNumeric(values) { +export function isNumeric(values: any[]): booleanish { for (const value of values) { if (value == null) continue; return typeof value === "number"; } } -export function isFirst(values, is) { +export function isFirst(values: any[], is: (d: any) => boolean): booleanish { for (const value of values) { if (value == null) continue; return is(value); @@ -298,7 +321,7 @@ export function isFirst(values, is) { // 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) { +export function isEvery(values: any[], is: (d: any) => boolean): boolean { for (const value of values) { if (value == null) continue; if (!is(value)) return false; @@ -311,26 +334,26 @@ export function isEvery(values, is) { // 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) { +export function isColor(value: UserOption): boolean { if (typeof value !== "string") return false; - value = value.toLowerCase().trim(); + value = value.toLowerCase().trim(); // !! typescript does not infer value as string (??) 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; + || ((value as string).startsWith("url(") && (value as string).endsWith(")")) // , e.g. pattern or gradient + || ((value as string).startsWith("var(") && (value as string).endsWith(")")) // CSS variable + || color(value as string) !== null; } -export function isNoneish(value) { +export function isNoneish(value: stringOption) { return value == null || isNone(value); } -export function isNone(value) { - return /^\s*none\s*$/i.test(value); +export function isNone(value: stringOption) { + return /^\s*none\s*$/i.test(value as string); } -export function isRound(value) { - return /^\s*round\s*$/i.test(value); +export function isRound(value: stringOption) { + return /^\s*round\s*$/i.test(value as string); } export function maybeFrameAnchor(value = "middle") { @@ -340,7 +363,7 @@ export function maybeFrameAnchor(value = "middle") { // 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) { +export function order(values : null | undefined | any[]) { if (values == null) return; const first = values[0]; const last = values[values.length - 1]; @@ -349,7 +372,7 @@ export function order(values) { // Unlike {...defaults, ...options}, this ensures that any undefined (but // present) properties in options inherit the given default value. -export function inherit(options = {}, ...rest) { +export function inherit(options: Record = {}, ...rest : Array>) { let o = options; for (const defaults of rest) { for (const key in defaults) { @@ -365,7 +388,7 @@ export function inherit(options = {}, ...rest) { // 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) { +export function Named(things: Iterable<{name: any}>): Record { console.warn("named iterables are deprecated; please use an object instead"); const names = new Set(); return Object.fromEntries(Array.from(things, thing => { @@ -379,6 +402,6 @@ export function Named(things) { })); } -export function maybeNamed(things) { +export function maybeNamed(things: any) { return isIterable(things) ? Named(things) : things; } 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 62% rename from src/scales/schemes.js rename to src/scales/schemes.ts index 923d72c59e..3a94a8902c 100644 --- a/src/scales/schemes.js +++ b/src/scales/schemes.ts @@ -1,3 +1,6 @@ +type ColorInterpolator = (t: number) => string; // t is in [0, 1] +type OrdinalScheme = string[] | (({length}: {length: number}) => string[]); // n is the number of colors + import { interpolateBlues, interpolateBrBG, @@ -91,27 +94,27 @@ const ordinalSchemes = new Map([ ["tableau10", schemeTableau10], // diverging - ["brbg", scheme11(schemeBrBG, interpolateBrBG)], - ["prgn", scheme11(schemePRGn, interpolatePRGn)], - ["piyg", scheme11(schemePiYG, interpolatePiYG)], - ["puor", scheme11(schemePuOr, interpolatePuOr)], - ["rdbu", scheme11(schemeRdBu, interpolateRdBu)], - ["rdgy", scheme11(schemeRdGy, interpolateRdGy)], - ["rdylbu", scheme11(schemeRdYlBu, interpolateRdYlBu)], - ["rdylgn", scheme11(schemeRdYlGn, interpolateRdYlGn)], - ["spectral", scheme11(schemeSpectral, interpolateSpectral)], + ["brbg", scheme11(schemeBrBG as Array, interpolateBrBG)], + ["prgn", scheme11(schemePRGn as Array, interpolatePRGn)], + ["piyg", scheme11(schemePiYG as Array, interpolatePiYG)], + ["puor", scheme11(schemePuOr as Array, interpolatePuOr)], + ["rdbu", scheme11(schemeRdBu as Array, interpolateRdBu)], + ["rdgy", scheme11(schemeRdGy as Array, interpolateRdGy)], + ["rdylbu", scheme11(schemeRdYlBu as Array, interpolateRdYlBu)], + ["rdylgn", scheme11(schemeRdYlGn as Array, interpolateRdYlGn)], + ["spectral", scheme11(schemeSpectral as Array, interpolateSpectral)], // reversed diverging (for temperature data) - ["burd", scheme11r(schemeRdBu, interpolateRdBu)], - ["buylrd", scheme11r(schemeRdYlBu, interpolateRdYlBu)], + ["burd", scheme11r(schemeRdBu as Array, interpolateRdBu)], + ["buylrd", scheme11r(schemeRdYlBu as Array, interpolateRdYlBu)], // sequential (single-hue) - ["blues", scheme9(schemeBlues, interpolateBlues)], - ["greens", scheme9(schemeGreens, interpolateGreens)], - ["greys", scheme9(schemeGreys, interpolateGreys)], - ["oranges", scheme9(schemeOranges, interpolateOranges)], - ["purples", scheme9(schemePurples, interpolatePurples)], - ["reds", scheme9(schemeReds, interpolateReds)], + ["blues", scheme9(schemeBlues as Array, interpolateBlues)], + ["greens", scheme9(schemeGreens as Array, interpolateGreens)], + ["greys", scheme9(schemeGreys as Array, interpolateGreys)], + ["oranges", scheme9(schemeOranges as Array, interpolateOranges)], + ["purples", scheme9(schemePurples as Array, interpolatePurples)], + ["reds", scheme9(schemeReds as Array, interpolateReds)], // sequential (multi-hue) ["turbo", schemei(interpolateTurbo)], @@ -123,26 +126,26 @@ const ordinalSchemes = new Map([ ["cubehelix", schemei(interpolateCubehelixDefault)], ["warm", schemei(interpolateWarm)], ["cool", schemei(interpolateCool)], - ["bugn", scheme9(schemeBuGn, interpolateBuGn)], - ["bupu", scheme9(schemeBuPu, interpolateBuPu)], - ["gnbu", scheme9(schemeGnBu, interpolateGnBu)], - ["orrd", scheme9(schemeOrRd, interpolateOrRd)], - ["pubu", scheme9(schemePuBu, interpolatePuBu)], - ["pubugn", scheme9(schemePuBuGn, interpolatePuBuGn)], - ["purd", scheme9(schemePuRd, interpolatePuRd)], - ["rdpu", scheme9(schemeRdPu, interpolateRdPu)], - ["ylgn", scheme9(schemeYlGn, interpolateYlGn)], - ["ylgnbu", scheme9(schemeYlGnBu, interpolateYlGnBu)], - ["ylorbr", scheme9(schemeYlOrBr, interpolateYlOrBr)], - ["ylorrd", scheme9(schemeYlOrRd, interpolateYlOrRd)], + ["bugn", scheme9(schemeBuGn as Array, interpolateBuGn)], + ["bupu", scheme9(schemeBuPu as Array, interpolateBuPu)], + ["gnbu", scheme9(schemeGnBu as Array, interpolateGnBu)], + ["orrd", scheme9(schemeOrRd as Array, interpolateOrRd)], + ["pubu", scheme9(schemePuBu as Array, interpolatePuBu)], + ["pubugn", scheme9(schemePuBuGn as Array, interpolatePuBuGn)], + ["purd", scheme9(schemePuRd as Array, interpolatePuRd)], + ["rdpu", scheme9(schemeRdPu as Array, interpolateRdPu)], + ["ylgn", scheme9(schemeYlGn as Array, interpolateYlGn)], + ["ylgnbu", scheme9(schemeYlGnBu as Array, interpolateYlGnBu)], + ["ylorbr", scheme9(schemeYlOrBr as Array, interpolateYlOrBr)], + ["ylorrd", scheme9(schemeYlOrRd as Array, interpolateYlOrRd)], // cyclical ["rainbow", schemeicyclical(interpolateRainbow)], ["sinebow", schemeicyclical(interpolateSinebow)] -]); +] as Array<[string, OrdinalScheme]>); -function scheme9(scheme, interpolate) { - return ({length: n}) => { +function scheme9(scheme: string[][], interpolate: ColorInterpolator) { + return ({length: n}: {length: number}) => { 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 +153,37 @@ function scheme9(scheme, interpolate) { }; } -function scheme11(scheme, interpolate) { - return ({length: n}) => { +function scheme11(scheme: string[][], 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: string[][], interpolate: ColorInterpolator) { + return ({length: n}: {length: number}) => { 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}: {length: number}) => 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}: {length: number}) => quantize(interpolate, Math.floor(n) + 1).slice(0, -1); } -export function ordinalScheme(scheme) { +export function ordinalScheme(scheme: string) { const s = `${scheme}`.toLowerCase(); if (!ordinalSchemes.has(s)) throw new Error(`unknown scheme: ${s}`); - return ordinalSchemes.get(s); + return ordinalSchemes.get(s) as OrdinalScheme; } -export function ordinalRange(scheme, length) { +export function ordinalRange(scheme: string, 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 +192,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: (boolean | number | Date | string | null | undefined)[], scheme = "greys") { const range = new Set(); const [f, t] = ordinalRange(scheme, 2); for (const value of domain) { @@ -253,7 +256,7 @@ const quantitativeSchemes = new Map([ ["sinebow", interpolateSinebow] ]); -export function quantitativeScheme(scheme) { +export function quantitativeScheme(scheme: string) { const s = `${scheme}`.toLowerCase(); if (!quantitativeSchemes.has(s)) throw new Error(`unknown scheme: ${s}`); return quantitativeSchemes.get(s); @@ -273,6 +276,6 @@ const divergingSchemes = new Set([ "buylrd" ]); -export function isDivergingScheme(scheme) { +export function isDivergingScheme(scheme: string | null | undefined) { return scheme != null && divergingSchemes.has(`${scheme}`.toLowerCase()); } diff --git a/src/stats.js b/src/stats.ts similarity index 83% rename from src/stats.js rename to src/stats.ts index f99d97d3cb..c61ff9cc69 100644 --- a/src/stats.js +++ b/src/stats.ts @@ -20,12 +20,12 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -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) { @@ -48,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)))); @@ -62,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( @@ -82,15 +82,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; @@ -122,22 +122,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 63% rename from src/style.js rename to src/style.ts index 1bc98a9369..41054f506b 100644 --- a/src/style.js +++ b/src/style.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type {IMark, IDimensions, IndexArray, Channel, ChannelObject, IScale, ISelection, IStyleObject, booleanOption, numberOption, stringOption, NumberChannel, TextChannel, UserOption, nullish} from "./common.js"; + import {group, namespaces} from "d3"; import {defined, nonempty} from "./defined.js"; import {formatDefault} from "./format.js"; @@ -9,7 +12,7 @@ export const offset = typeof window !== "undefined" && window.devicePixelRatio > let nextClipId = 0; export function styles( - mark, + mark: IMark, { title, href, @@ -32,7 +35,29 @@ export function styles( paintOrder, pointerEvents, shapeRendering - }, + } : { + title?: stringOption, + href?: stringOption, + ariaLabel: stringOption, + ariaDescription?: stringOption, + ariaHidden: booleanOption, + target?: stringOption, + fill?: stringOption, + fillOpacity?: numberOption, + stroke?: stringOption, + strokeWidth?: numberOption, + strokeOpacity?: numberOption, + strokeLinejoin?: stringOption, + strokeLinecap?: stringOption, + strokeMiterlimit?: numberOption, + strokeDasharray?: stringOption, + strokeDashoffset?: stringOption, + opacity?: numberOption, + mixBlendMode: stringOption, + paintOrder?: stringOption, + pointerEvents?: stringOption, + shapeRendering?: stringOption + } /* options */, { ariaLabel: cariaLabel, fill: defaultFill = "currentColor", @@ -44,7 +69,18 @@ export function styles( strokeLinejoin: defaultStrokeLinejoin, strokeMiterlimit: defaultStrokeMiterlimit, paintOrder: defaultPaintOrder - } + } : { + ariaLabel?: string, + fill?: string, + fillOpacity?: number, + stroke?: string, + strokeOpacity?: number, + strokeWidth?: number, + strokeLinecap?: string, + strokeLinejoin?: string, + strokeMiterlimit?: number, + paintOrder?: string + } /* defaults */ ) { // Some marks don’t support fill (e.g., tick and rule). @@ -139,52 +175,66 @@ export function styles( } // Applies the specified titles via selection.call. -export function applyTitle(selection, L) { +export function applyTitle(selection: ISelection, L?: TextChannel) { if (L) selection.filter(i => nonempty(L[i])).append("title").call(applyText, L); } // Like applyTitle, but for grouped data (lines, areas). -export function applyTitleGroup(selection, L) { - if (L) selection.filter(([i]) => nonempty(L[i])).append("title").call(applyTextGroup, L); +export function applyTitleGroup(selection: ISelection, L?: TextChannel) { + if (L) selection.filter(([i]: [number]) => 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: ISelection, T: TextChannel) { + selection.text((i: number) => formatDefault(T[i])); } -export function applyTextGroup(selection, T) { - if (T) selection.text(([i]) => formatDefault(T[i])); +export function applyTextGroup(selection: ISelection, T: TextChannel) { + selection.text(([i]: [number]) => formatDefault(T[i])); } -export function applyChannelStyles(selection, {target}, {ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H}) { - 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); +export function applyChannelStyles( + selection: ISelection, + {target}: {target: string | nullish}, + {ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H} +: {ariaLabel?: TextChannel, title?: TextChannel, fill?: TextChannel, fillOpacity?: NumberChannel, stroke?: TextChannel, strokeOpacity?: NumberChannel, strokeWidth?: NumberChannel, opacity?: NumberChannel, href?: TextChannel} +) { + if (AL) applyAttr(selection, "aria-label", (i: number) => AL[i]); + if (F) applyAttr(selection, "fill", (i: number) => F[i]); + if (FO) applyAttr(selection, "fill-opacity", (i: number) => FO[i]); + if (S) applyAttr(selection, "stroke", (i: number) => S[i]); + if (SO) applyAttr(selection, "stroke-opacity", (i: number) => SO[i]); + if (SW) applyAttr(selection, "stroke-width", (i: number) => SW[i]); + if (O) applyAttr(selection, "opacity", (i: number) => O[i]); + if (H) applyHref(selection, (i: number) => H[i], target); applyTitle(selection, T); } -export function applyGroupedChannelStyles(selection, {target}, {ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H}) { - 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); +export function applyGroupedChannelStyles( + selection: ISelection, + {target}: {target: string | nullish}, + {ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H} +: {ariaLabel?: TextChannel, title?: TextChannel, fill?: TextChannel, fillOpacity?: NumberChannel, stroke?: TextChannel, strokeOpacity?: NumberChannel, strokeWidth?: NumberChannel, opacity?: NumberChannel, href?: TextChannel} +) { + if (AL) applyAttr(selection, "aria-label", ([i]: [number]) => AL[i]); + if (F) applyAttr(selection, "fill", ([i]: [number]) => F[i]); + if (FO) applyAttr(selection, "fill-opacity", ([i]: [number]) => FO[i]); + if (S) applyAttr(selection, "stroke", ([i]: [number]) => S[i]); + if (SO) applyAttr(selection, "stroke-opacity", ([i]: [number]) => SO[i]); + if (SW) applyAttr(selection, "stroke-width", ([i]: [number]) => SW[i]); + if (O) applyAttr(selection, "opacity", ([i]: [number]) => O[i]); + if (H) applyHref(selection, ([i]: [number]) => H[i], target); applyTitleGroup(selection, T); } -function groupAesthetics({ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H}) { - return [AL, T, F, FO, S, SO, SW, O, H].filter(c => c !== undefined); +function groupAesthetics({ + ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H +}: { + ariaLabel?: TextChannel, title?: TextChannel, fill?: TextChannel, fillOpacity?: NumberChannel, stroke?: TextChannel, strokeOpacity?: NumberChannel, strokeWidth?: NumberChannel, opacity?: NumberChannel, href?: TextChannel +}) { + return [AL, T, F, FO, S, SO, SW, O, H].filter(c => c !== undefined) as (TextChannel | NumberChannel)[]; } -export function groupZ(I, Z, z) { +export function groupZ(I: IndexArray, Z: Channel, z: UserOption) { const G = group(I, i => Z[i]); if (z === undefined && G.size > I.length >> 1) { warn(`Warning: the implicit z channel has high cardinality. This may occur when the fill or stroke channel is associated with quantitative data rather than ordinal or categorical data. You can suppress this warning by setting the z option explicitly; if this data represents a single series, set z to null.`); @@ -192,7 +242,7 @@ export function groupZ(I, Z, z) { return G.values(); } -export function* groupIndex(I, position, {z}, channels) { +export function* groupIndex(I: IndexArray, position: NumberChannel[], {z}: IMark, channels: ChannelObject) { const {z: Z} = channels; // group channel const A = groupAesthetics(channels); // aesthetic channels const C = [...position, ...A]; // all channels @@ -200,7 +250,7 @@ export function* groupIndex(I, position, {z}, channels) { // Group the current index by Z (if any). 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 + let Gg: number[] | undefined; // the current group index (a subset of G, and I), if any out: for (const i of G) { // If any channel has an undefined value for this index, skip it. @@ -222,7 +272,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 number[]).push(i); for (let j = 0; j < A.length; ++j) { const k = keyof(A[j][i]); if (k !== Ag[j]) { @@ -241,13 +291,13 @@ 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: booleanOption) { 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: ISelection, mark: IMark, scales: Record<"x" | "y", IScale>, dimensions: IDimensions) { applyAttr(selection, "aria-label", mark.ariaLabel); applyAttr(selection, "aria-description", mark.ariaDescription); applyAttr(selection, "aria-hidden", mark.ariaHidden); @@ -280,33 +330,33 @@ export function applyIndirectStyles(selection, mark, scales, dimensions) { } } -export function applyDirectStyles(selection, mark) { +export function applyDirectStyles(selection: ISelection, mark: IMark) { applyStyle(selection, "mix-blend-mode", mark.mixBlendMode); applyAttr(selection, "opacity", mark.opacity); } -function applyHref(selection, href, target) { - selection.each(function(i) { +function applyHref(selection: ISelection, href: (d: any) => string, target: string | nullish) { + selection.each(function(this: Element, 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); + (this.parentNode as Element).insertBefore(a, this).appendChild(this); } }); } -export function applyAttr(selection, name, value) { +export function applyAttr(selection: ISelection, name: string, value: any) { if (value != null) selection.attr(name, value); } -export function applyStyle(selection, name, value) { +export function applyStyle(selection: ISelection, name: string, value: any) { if (value != null) selection.style(name, value); } -export function applyTransform(selection, mark, {x, y}, tx = offset, ty = offset) { +export function applyTransform(selection: ISelection, mark: IMark, {x, y}: {x?: IScale, y?: IScale}, tx = offset, ty = offset) { tx += mark.dx; ty += mark.dy; if (x?.bandwidth) tx += x.bandwidth() / 2; @@ -314,24 +364,24 @@ 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) { +export function impliedString(value: any, impliedValue: string): string | nullish { if ((value = string(value)) !== impliedValue) return value; } -export function impliedNumber(value, impliedValue) { +export function impliedNumber(value: any, impliedValue: number): number | nullish { if ((value = number(value)) !== impliedValue) return value; } 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: UserOption) { if (name === undefined) return `plot-${Math.random().toString(16).slice(2)}`; name = `${name}`; - if (!validClassName.test(name)) throw new Error(`invalid class name: ${name}`); + if (!validClassName.test(name as string)) throw new Error(`invalid class name: ${name}`); return name; } -export function applyInlineStyles(selection, style) { +export function applyInlineStyles(selection: ISelection, style: IStyleObject) { if (typeof style === "string") { selection.property("style", style); } else if (style != null) { @@ -341,9 +391,9 @@ export function applyInlineStyles(selection, style) { } } -export function applyFrameAnchor({frameAnchor}, {width, height, marginTop, marginRight, marginBottom, marginLeft}) { +export function applyFrameAnchor({frameAnchor}: IMark, {width, height, marginTop, marginRight, marginBottom, marginLeft}: IDimensions) { return [ - /left$/.test(frameAnchor) ? marginLeft : /right$/.test(frameAnchor) ? width - marginRight : (marginLeft + width - marginRight) / 2, - /^top/.test(frameAnchor) ? marginTop : /^bottom/.test(frameAnchor) ? height - marginBottom : (marginTop + height - marginBottom) / 2 + /left$/.test(frameAnchor as string) ? marginLeft : /right$/.test(frameAnchor as string) ? width - marginRight : (marginLeft + width - marginRight) / 2, + /^top/.test(frameAnchor as string) ? marginTop : /^bottom/.test(frameAnchor as string) ? height - marginBottom : (marginTop + height - marginBottom) / 2 ]; } diff --git a/src/symbols.js b/src/symbols.ts similarity index 72% rename from src/symbols.js rename to src/symbols.ts index e35aaec694..9d1f91647b 100644 --- a/src/symbols.js +++ b/src/symbols.ts @@ -1,10 +1,14 @@ +type SymbolString = "asterisk" | "circle" | "cross" | "diamond" | "diamond2" | "hexagon" | "plus" | "square" | "square2" | "star" | "times" | "triangle" | "triangle2" | "wye"; +type SymbolObject = {draw: (context: CanvasPath, size: number) => void}; +type MaybeSymbol = SymbolString | SymbolObject | null | undefined; + 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, hy = ry / 2; context.moveTo(0, ry); @@ -34,24 +38,24 @@ const symbols = new Map([ ["wye", symbolWye] ]); -function isSymbolObject(value) { - return value && typeof value.draw === "function"; +function isSymbolObject(value: MaybeSymbol) { + 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.ts similarity index 60% rename from src/transforms/basic.js rename to src/transforms/basic.ts index 02fafd0cb2..60f06ff47c 100644 --- a/src/transforms/basic.js +++ b/src/transforms/basic.ts @@ -1,3 +1,7 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable prefer-const */ +import type {IDimensions, IMark, Comparator, Channel, ConstantOrFieldOption, MarkOptions, MaybeFacetArray, TransformFunction, nullish, IndexArray} from "../common.js"; + import {randomLcg} from "d3"; import {ascendingDefined, descendingDefined} from "../defined.js"; import {arrayify, isDomainSort, isOptions, maybeValue, valueof} from "../options.js"; @@ -11,7 +15,7 @@ export function basic({ transform: t1, initializer: i1, ...options -} = {}, t2) { +}: MarkOptions = {}, t2: TransformFunction): 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)); @@ -33,7 +37,7 @@ export function initializer({ reverse: r1, initializer: i1, ...options -} = {}, i2) { +}: MarkOptions = {}, i2: TransformFunction): 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)); @@ -45,19 +49,19 @@ export function initializer({ }; } -function composeTransform(t1, t2) { +function composeTransform(t1: TransformFunction | nullish, t2: TransformFunction | nullish) { if (t1 == null) return t2 === null ? undefined : t2; if (t2 == null) return t1 === null ? undefined : t1; - return function(data, facets) { + return function(this: IMark, data: any, facets: MaybeFacetArray) { ({data, facets} = t1.call(this, data, facets)); return t2.call(this, arrayify(data), facets); }; } -function composeInitializer(i1, i2) { +function composeInitializer(i1: TransformFunction | nullish, i2: TransformFunction | nullish) { if (i1 == null) return i2 === null ? undefined : i2; if (i2 == null) return i1 === null ? undefined : i1; - return function(data, facets, channels, scales, dimensions) { + return function(this: IMark, data: any, facets: MaybeFacetArray, channels?: any, scales?: any, dimensions?: IDimensions) { 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)); @@ -65,50 +69,50 @@ function composeInitializer(i1, i2) { }; } -function apply(options, t) { +function apply(options: any, t: any): MarkOptions { return (options.initializer != null ? initializer : basic)(options, t); } -export function filter(value, options) { +export function filter(value: ConstantOrFieldOption, options: MarkOptions) { return apply(options, filterTransform(value)); } -function filterTransform(value) { +function filterTransform(value: ConstantOrFieldOption): TransformFunction { return (data, facets) => { - const V = valueof(data, value); - return {data, facets: facets.map(I => I.filter(i => V[i]))}; + const V = valueof(data, value) || []; + return {data, facets: facets && facets.map(I => I.filter((i: number) => V[i]))}; }; } -export function reverse(options) { +export function reverse(options: MarkOptions) { return {...apply(options, reverseTransform), sort: null}; } -function reverseTransform(data, facets) { - return {data, facets: facets.map(I => I.slice().reverse())}; +function reverseTransform(data: any, facets: MaybeFacetArray) { + return {data, facets: facets && facets.map(I => I.slice().reverse())}; } -export function shuffle({seed, ...options} = {}) { +export function shuffle({seed, ...options}: {seed?: number | null} = {}) { return {...apply(options, sortValue(seed == null ? Math.random : randomLcg(seed))), sort: null}; } -export function sort(value, options) { +export function sort(value: any, options: MarkOptions) { return {...(isOptions(value) && value.channel !== undefined ? initializer : apply)(options, sortTransform(value)), sort: null}; } -function sortTransform(value) { +function sortTransform(value: any): TransformFunction { 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 sortData(compare: Comparator): TransformFunction { + return (data: any, facets: MaybeFacetArray) => { + const compareData = (i: number, j: number) => compare(data[i], data[j]); + return {data, facets: facets && facets.map(I => I.slice().sort(compareData))}; }; } -function sortValue(value) { - let channel, order; +function sortValue(value: any): TransformFunction { + let channel: string | undefined, order: Comparator; ({channel, value, order = ascendingDefined} = {...maybeValue(value)}); if (typeof order !== "function") { switch (`${order}`.toLowerCase()) { @@ -117,17 +121,16 @@ function sortValue(value) { default: throw new Error(`invalid order: ${order}`); } } - return (data, facets, channels) => { - let V; + return (data: any, facets: MaybeFacetArray, channels: any) => { + let V: Channel | nullish; if (channel === undefined) { V = valueof(data, value); } else { if (channels === undefined) throw new Error("channel sort requires an initializer"); - V = channels[channel]; + V = channels[channel]?.value; 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))}; + const compareValue = (i: number, j: number) => order((V as Channel)[i], (V as Channel)[j]); + return {data, facets: facets && facets.map((I: IndexArray) => I.slice().sort(compareValue))}; }; } diff --git a/src/transforms/group.js b/src/transforms/group.ts similarity index 59% rename from src/transforms/group.js rename to src/transforms/group.ts index f0758b32e3..c63beb0270 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.ts @@ -1,30 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type {MarkOptions, MarkOptionsDefined, ConstantOrFieldOption, PXX, FieldOptionsKey, nullish, IndexArray, booleanOption, Reduce1, AggregationMethod, OutputOptions, Column} from "../common.js"; + +type ComputedReducer = { + name?: FieldOptionsKey, + output?: Column[0], + initialize: (data: any) => void, + scope: (scope?: any, I?: IndexArray) => void, + reduce: (I: IndexArray, data?: any) => any, + label?: string +}; + import {group as grouper, sort, sum, deviation, min, max, mean, median, mode, variance, InternSet, minIndex, maxIndex, rollup} from "d3"; import {ascendingDefined} from "../defined.js"; import {valueof, maybeColorChannel, maybeInput, maybeTuple, maybeColumn, column, first, identity, take, labelof, range, second, percentile} from "../options.js"; import {basic} from "./basic.js"; // Group on {z, fill, stroke}. -export function groupZ(outputs, options) { +export function groupZ(outputs: OutputOptions, options: 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: MarkOptionsDefined = {}) { 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: MarkOptionsDefined = {}) { 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 = {}) { - let {x, y} = options; +export function group(outputs: OutputOptions = {fill: "count"}, options: MarkOptionsDefined = {}) { + let {x, y}: {x?: ConstantOrFieldOption, y?: ConstantOrFieldOption} = options; ([x, y] = maybeTuple(x, y)); if (x == null) throw new Error("missing channel: x"); if (y == null) throw new Error("missing channel: y"); @@ -32,23 +44,23 @@ export function group(outputs = {fill: "count"}, options = {}) { } function groupn( - x, // optionally group on x - y, // optionally group on y + x: ConstantOrFieldOption, // optionally group on x + y: ConstantOrFieldOption, // optionally group on y { - data: reduceData = reduceIdentity, - filter, - sort, + data: reduceData1 = reduceIdentity, + filter: filter1, + sort: sort1, reverse, - ...outputs // output channel definitions - } = {}, - inputs = {} // input channels and options + ...outputs1 // output channel definitions + }: OutputOptions = {}, + inputs: MarkOptionsDefined = {} // input channels and options ) { // Compute the outputs. - outputs = maybeOutputs(outputs, inputs); - reduceData = maybeReduce(reduceData, identity); - sort = sort == null ? undefined : maybeOutput("sort", sort, inputs); - filter = filter == null ? undefined : maybeEvaluator("filter", filter, inputs); + const outputs = maybeOutputs(outputs1, inputs); + const reduceData = maybeReduce(reduceData1, identity); + const sort = sort1 == null ? undefined : maybeOutput("sort", sort1, inputs); + const filter = filter1 == null ? undefined : maybeEvaluator("filter", filter1, inputs); // Produce x and y output channels as appropriate. const [GX, setGX] = maybeColumn(x); @@ -83,17 +95,17 @@ function groupn( const S = valueof(data, vstroke); 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 groupData: number[] = []; + const GX = X && (setGX as Column[1])([]); + const GY = Y && (setGY as Column[1])([]); + const GZ = Z && (setGZ as Column[1])([]); + const GF = F && (setGF as Column[1])([]); + const GS = S && (setGS as Column[1])([]); let i = 0; for (const o of outputs) o.initialize(data); if (sort) sort.initialize(data); if (filter) filter.initialize(data); - for (const facet of facets) { + for (const facet of facets as IndexArray[]) { const groupFacet = []; for (const o of outputs) o.scope("facet", facet); if (sort) sort.scope("facet", facet); @@ -104,11 +116,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 (GX) GX.push(x); + if (GY) GY.push(y); + if (GZ) GZ.push(G === Z ? f : Z[g[0]]); + if (GF) GF.push(G === F ? f : F[g[0]]); + if (GS) GS.push(G === S ? f : S[g[0]]); for (const o of outputs) o.reduce(g); if (sort) sort.reduce(g); } @@ -125,65 +137,65 @@ function groupn( }; } -export function hasOutput(outputs, ...names) { +export function hasOutput(outputs: ComputedReducer[], ...names: string[]) { for (const {name} of outputs) { - if (names.includes(name)) { + if (names.includes(name as string)) { return true; } } return false; } -export function maybeOutputs(outputs, inputs) { +export function maybeOutputs(outputs: OutputOptions, inputs: MarkOptionsDefined): ComputedReducer[] { 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 entries.map(([name, reduce]: [string, AggregationMethod | undefined]) => { return reduce == null - ? {name, initialize() {}, scope() {}, reduce() {}} - : maybeOutput(name, reduce, inputs); + ? {name, initialize() {}, scope() {}, reduce() {}} as ComputedReducer // type name to a FieldOptionsKey + : maybeOutput(name as FieldOptionsKey, reduce, inputs); }); } -export function maybeOutput(name, reduce, inputs) { +export function maybeOutput(name: FieldOptionsKey, reduce: any, inputs: any) { const evaluator = maybeEvaluator(name, reduce, inputs); const [output, setOutput] = column(evaluator.label); let O; return { name, output, - initialize(data) { + initialize(data: any) { evaluator.initialize(data); - O = setOutput([]); + O = (setOutput as Column[1])([]); }, - scope(scope, I) { + scope(scope?: "data" | "facet", I?: IndexArray) { evaluator.scope(scope, I); }, - reduce(I, extent) { + reduce(I: IndexArray, extent?: string) { O.push(evaluator.reduce(I, extent)); } }; } -export function maybeEvaluator(name, reduce, inputs) { - const input = maybeInput(name, inputs); +export function maybeEvaluator(name: string, reduce: any, inputs: any): ComputedReducer { + const input = maybeInput(name as FieldOptionsKey, inputs); const reducer = maybeReduce(reduce, input); - let V, context; + let V: ArrayLike | nullish, context: any; return { label: labelof(reducer === reduceCount ? null : input, reducer.label), - initialize(data) { + initialize(data: ArrayLike) { V = input === undefined ? data : valueof(data, input); if (reducer.scope === "data") { context = reducer.reduce(range(data), V); } }, - scope(scope, I) { + scope(scope?: "data" | "facet", I?: IndexArray) { if (reducer.scope === scope) { - context = reducer.reduce(I, V); + context = reducer.reduce(I as IndexArray, V); } }, - reduce(I, extent) { + reduce(I: IndexArray, extent?: string) { return reducer.scope == null ? reducer.reduce(I, V, extent) : reducer.reduce(I, V, context, extent); @@ -191,14 +203,14 @@ export function maybeEvaluator(name, reduce, inputs) { }; } -export function maybeGroup(I, X) { +export function maybeGroup(I: IndexArray, X: ArrayLike | nullish): [any, IndexArray][] { return X ? sort(grouper(I, i => X[i]), first) : [[, I]]; } -export function maybeReduce(reduce, value) { - if (reduce && typeof reduce.reduce === "function") return reduce; +export function maybeReduce(reduce: AggregationMethod, value: any): Reduce1 { + if (reduce && typeof (reduce as Reduce1).reduce === "function") return reduce as Reduce1; 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)); switch (`${reduce}`.toLowerCase()) { case "first": return reduceFirst; case "last": return reduceLast; @@ -226,19 +238,19 @@ export function maybeReduce(reduce, value) { throw new Error(`invalid reduce: ${reduce}`); } -export function maybeSubgroup(outputs, inputs) { +export function maybeSubgroup(outputs: ComputedReducer[], inputs: {z?: any, stroke?: any, fill?: any}) { for (const name in inputs) { - const value = inputs[name]; + const value = inputs[name as "z" | "stroke" | "fill"]; if (value !== undefined && !outputs.some(o => o.name === name)) { return value; } } } -export function maybeSort(facets, sort, reverse) { +export function maybeSort(facets: IndexArray[], sort: ComputedReducer & {output: Column[0]} | nullish, reverse: booleanOption) { if (sort) { const S = sort.output.transform(); - const compare = (i, j) => ascendingDefined(S[i], S[j]); + const compare = (i: number, j: number) => ascendingDefined(S[i], S[j]); facets.forEach(f => f.sort(compare)); } if (reverse) { @@ -246,36 +258,36 @@ export function maybeSort(facets, sort, reverse) { } } -function reduceFunction(f) { +function reduceFunction(f: (X?: ArrayLike, extent?: any) => any) { return { - reduce(I, X, extent) { + reduce(I: IndexArray, X: any[], extent?: any) { return f(take(X, I), extent); } }; } -function reduceAccessor(f) { +function reduceAccessor(f: (I: IndexArray, a: (i: number) => any) => any) { return { - reduce(I, X) { - return f(I, i => X[i]); + reduce(I: IndexArray, X: ArrayLike) { + return f(I, (i: number) => X[i]); } }; } export const reduceIdentity = { - reduce(I, X) { + reduce(I: IndexArray, X: any) { return take(X, I); } }; export const reduceFirst = { - reduce(I, X) { + reduce(I: IndexArray, X: any) { return X[I[0]]; } }; const reduceTitle = { - reduce(I, X) { + reduce(I: IndexArray, X: any) { const n = 5; const groups = sort(rollup(I, V => V.length, i => X[i]), second); const top = groups.slice(-n).reverse(); @@ -288,21 +300,21 @@ const reduceTitle = { }; const reduceLast = { - reduce(I, X) { + reduce(I: IndexArray, X: any) { return X[I[I.length - 1]]; } }; export const reduceCount = { label: "Frequency", - reduce(I) { + reduce(I: IndexArray) { return I.length; } }; const reduceDistinct = { label: "Distinct", - reduce: (I, X) => { + reduce: (I: IndexArray, X: any) => { const s = new InternSet(); for (const i of I) s.add(X[i]); return s.size; @@ -311,49 +323,49 @@ const reduceDistinct = { const reduceSum = reduceAccessor(sum); -function reduceProportion(value, scope) { +function reduceProportion(value: any, scope: "data" | "facet") { 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: IndexArray, V: any, basis = 1) => I.length / basis} + : {scope, reduce: (I: IndexArray, V: any, basis = 1) => sum(I, i => V[i]) / basis}; } -function mid(x1, x2) { +function mid(x1: number | Date, x2: number | Date): number | Date { const m = (+x1 + +x2) / 2; return x1 instanceof Date ? new Date(m) : m; } const reduceX = { - reduce(I, X, {x1, x2}) { + reduce(I: IndexArray, X: any, {x1, x2}: any) { return mid(x1, x2); } }; const reduceY = { - reduce(I, X, {y1, y2}) { + reduce(I: IndexArray, X: any, {y1, y2}: any) { return mid(y1, y2); } }; const reduceX1 = { - reduce(I, X, {x1}) { + reduce(I: IndexArray, X: any, {x1}: any) { return x1; } }; const reduceX2 = { - reduce(I, X, {x2}) { + reduce(I: IndexArray, X: any, {x2}: any) { return x2; } }; const reduceY1 = { - reduce(I, X, {y1}) { + reduce(I: IndexArray, X: any, {y1}: any) { return y1; } }; const reduceY2 = { - reduce(I, X, {y2}) { + reduce(I: IndexArray, X: any, {y2}: any) { return y2; } }; diff --git a/src/transforms/identity.js b/src/transforms/identity.ts similarity index 66% rename from src/transforms/identity.js rename to src/transforms/identity.ts index c32145f8a1..c3e77c6aba 100644 --- a/src/transforms/identity.js +++ b/src/transforms/identity.ts @@ -1,13 +1,14 @@ +import type {MarkOptions} from "../common.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} diff --git a/src/transforms/inset.js b/src/transforms/inset.ts similarity index 62% rename from src/transforms/inset.js rename to src/transforms/inset.ts index 46cd750d44..685b703fb2 100644 --- a/src/transforms/inset.js +++ b/src/transforms/inset.ts @@ -1,16 +1,19 @@ +import type {InsetOption} from "../common.js"; +type maybeInsetArgs = {inset?: InsetOption, insetLeft?: InsetOption, insetRight?: InsetOption, insetTop?: InsetOption, insetBottom?: InsetOption} + import {offset} from "../style.js"; -export function maybeInsetX({inset, insetLeft, insetRight, ...options} = {}) { +export function maybeInsetX({inset, insetLeft, insetRight, ...options}: maybeInsetArgs = {}) { ([insetLeft, insetRight] = maybeInset(inset, insetLeft, insetRight)); return {inset, insetLeft, insetRight, ...options}; } -export function maybeInsetY({inset, insetTop, insetBottom, ...options} = {}) { +export function maybeInsetY({inset, insetTop, insetBottom, ...options}: maybeInsetArgs = {}) { ([insetTop, insetBottom] = maybeInset(inset, insetTop, insetBottom)); return {inset, insetTop, insetBottom, ...options}; } -function maybeInset(inset, inset1, inset2) { +function maybeInset(inset: InsetOption, inset1: InsetOption, inset2: InsetOption) { return inset === undefined && inset1 === undefined && inset2 === undefined ? (offset ? [1, 0] : [0.5, 0.5]) : [inset1, inset2]; diff --git a/src/transforms/map.js b/src/transforms/map.ts similarity index 57% rename from src/transforms/map.js rename to src/transforms/map.ts index 2813d5cd04..c7fd8501d5 100644 --- a/src/transforms/map.js +++ b/src/transforms/map.ts @@ -1,23 +1,28 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type {MapMethod, IndexArray, Channel, FieldOptions, FieldOptionsKey, nullish} from "../common.js"; +type ComputedMapMethod = {map: (I: IndexArray, S: Channel, T: any[]) => any}; + + 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: MapMethod, options: FieldOptions = {}) { return map(Object.fromEntries(["x", "x1", "x2"] - .filter(key => options[key] != null) + .filter(key => options[key as "x" | "x1" | "x2"] != null) .map(key => [key, m])), options); } -export function mapY(m, options = {}) { +export function mapY(m: MapMethod, options: FieldOptions = {}) { return map(Object.fromEntries(["y", "y1", "y2"] - .filter(key => options[key] != null) + .filter(key => options[key as "y" | "y1" | "y2"] != null) .map(key => [key, m])), options); } -export function map(outputs = {}, options = {}) { +export function map(outputs: Record = {}, options = {}) { const z = maybeZ(options); const channels = Object.entries(outputs).map(([key, map]) => { - const input = maybeInput(key, options); + const input = maybeInput(key as FieldOptionsKey, options); if (input == null) throw new Error(`missing channel: ${key}`); const [output, setOutput] = column(input); return {key, input, output, setOutput, map: maybeMap(map)}; @@ -27,9 +32,9 @@ export function map(outputs = {}, options = {}) { const Z = valueof(data, z); 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 facet of facets as IndexArray[]) { 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 any[], MX[i])); } } return {data, facets}; @@ -38,9 +43,9 @@ export function map(outputs = {}, options = {}) { }; } -function maybeMap(map) { - if (map && typeof map.map === "function") return map; - if (typeof map === "function") return mapFunction(map); +function maybeMap(map: MapMethod): ComputedMapMethod { + if (map && typeof (map as ComputedMapMethod).map === "function") return map as ComputedMapMethod; + if (typeof map === "function") return mapFunction(map as () => any); switch (`${map}`.toLowerCase()) { case "cumsum": return mapCumsum; case "rank": return mapFunction(rank); @@ -49,14 +54,14 @@ function maybeMap(map) { throw new Error(`invalid map: ${map}`); } -function rankQuantile(V) { +function rankQuantile(V: Iterable) { const n = count(V) - 1; return rank(V).map(r => r / n); } -function mapFunction(f) { +function mapFunction(f: (S: Iterable) => any) { return { - map(I, S, T) { + map(I: IndexArray, S: Channel, T: any[]) { 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]; @@ -65,7 +70,7 @@ function mapFunction(f) { } const mapCumsum = { - map(I, S, T) { + map(I: IndexArray, S: Channel, T: any[]) { let sum = 0; for (const i of I) T[i] = sum += S[i]; } diff --git a/src/transforms/select.js b/src/transforms/select.ts similarity index 53% rename from src/transforms/select.js rename to src/transforms/select.ts index 8dbc090c3a..b907105b51 100644 --- a/src/transforms/select.js +++ b/src/transforms/select.ts @@ -1,8 +1,14 @@ +import type {IndexArray, Channel, MarkOptions, MarkOptionsDefined, FieldOptionsKey, FieldOptions, ConstantOrFieldOption, nullish} from "../common.js"; + import {greatest, group, least} from "d3"; import {maybeZ, valueof} from "../options.js"; import {basic} from "./basic.js"; -export function select(selector, options = {}) { +export type Selector = string | "first" | "last" | ComputedSelector; +export type MultiSelector = Record; +type ComputedSelector = (((I: IndexArray, X: Channel) => IterableIterator | Generator) | ((I: IndexArray, X?: Channel) => IterableIterator | Generator)); + +export function select(selector: Selector | MultiSelector, options: MarkOptions = {}): 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") { @@ -10,6 +16,7 @@ export function select(selector, options = {}) { case "first": return selectFirst(options); case "last": return selectLast(options); } + throw new Error(`invalid selector: ${selector}`); } if (typeof selector === "function") { return selectChannel(null, selector, options); @@ -20,13 +27,13 @@ export function select(selector, options = {}) { let key, value; for (key in selector) { if (value !== undefined) throw new Error("ambiguous selector; multiple inputs"); - value = maybeSelector(selector[key]); + value = maybeSelector(selector[key as string]); } if (value === undefined) throw new Error(`invalid selector: ${selector}`); - return selectChannel(key, value, options); + return selectChannel(key as FieldOptionsKey, value, options); } -function maybeSelector(selector) { +function maybeSelector(selector: Selector): ComputedSelector { if (typeof selector === "function") return selector; switch (`${selector}`.toLowerCase()) { case "min": return selectorMin; @@ -35,61 +42,64 @@ function maybeSelector(selector) { throw new Error(`unknown selector: ${selector}`); } -export function selectFirst(options) { +export function selectFirst(options: MarkOptionsDefined) { return selectChannel(null, selectorFirst, options); } -export function selectLast(options) { +export function selectLast(options: MarkOptionsDefined) { return selectChannel(null, selectorLast, options); } -export function selectMinX(options) { +export function selectMinX(options: MarkOptionsDefined) { return selectChannel("x", selectorMin, options); } -export function selectMinY(options) { +export function selectMinY(options: MarkOptionsDefined) { return selectChannel("y", selectorMin, options); } -export function selectMaxX(options) { +export function selectMaxX(options: MarkOptionsDefined) { return selectChannel("x", selectorMax, options); } -export function selectMaxY(options) { +export function selectMaxY(options: MarkOptionsDefined) { return selectChannel("y", selectorMax, options); } -function* selectorFirst(I) { +function* selectorFirst(I: IndexArray) { yield I[0]; } -function* selectorLast(I) { +function* selectorLast(I: IndexArray) { yield I[I.length - 1]; } -function* selectorMin(I, X) { +function* selectorMin(I: IndexArray, X: Channel) { yield least(I, i => X[i]); } -function* selectorMax(I, X) { +function* selectorMax(I: IndexArray, X: Channel) { 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(v1: FieldOptionsKey | nullish, selector: ComputedSelector, options: FieldOptions) { + let v: ConstantOrFieldOption; + if (v1 != null) { + if (options[v1] == null) throw new Error(`missing channel: ${v}`); + v = options[v1]; + } else { + v = v1; } const z = maybeZ(options); return basic(options, (data, facets) => { const Z = valueof(data, z); const V = valueof(data, v); const selectFacets = []; - for (const facet of facets) { + for (const facet of facets as IndexArray[]) { const selectFacet = []; for (const I of Z ? group(facet, i => Z[i]).values() : [facet]) { - for (const i of selector(I, V)) { - selectFacet.push(i); + for (const i of selector(I, V as Channel)) { + if (i !== undefined) selectFacet.push(i); } } selectFacets.push(selectFacet);