From 69a7b574fce1815cd162c442cbaf054d4126429e Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 May 2023 17:23:55 -0700 Subject: [PATCH 1/2] index-aware reducer functions --- docs/transforms/normalize.md | 1 + docs/transforms/window.md | 2 +- src/options.js | 5 +++ src/reducer.d.ts | 2 +- src/transforms/normalize.d.ts | 4 +- src/transforms/normalize.js | 4 +- src/transforms/window.js | 42 ++++++++--------- test/output/aaplCloseNormalize.svg | 72 ++++++++++++++++++++++++++++++ test/plots/aapl-close.ts | 28 ++++++++++++ 9 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 test/output/aaplCloseNormalize.svg diff --git a/docs/transforms/normalize.md b/docs/transforms/normalize.md index 397303f5f5..c2fd1e9b87 100644 --- a/docs/transforms/normalize.md +++ b/docs/transforms/normalize.md @@ -120,6 +120,7 @@ The **basis** option specifies how to normalize the series values; it is one of: * *extent* - the minimum is mapped to zero, and the maximum to one * *deviation* - subtract the mean, then divide by the standard deviation * a function to be passed an array of values, returning the desired basis +* a function to be passed an index and channel value array, returning the desired basis ## normalize(*basis*) diff --git a/docs/transforms/window.md b/docs/transforms/window.md index 189795e12d..14dac8c4e1 100644 --- a/docs/transforms/window.md +++ b/docs/transforms/window.md @@ -181,7 +181,7 @@ The following named reducers are supported: * *first* - the first value * *last* - the last value -A reducer may also be specified as a function to be passed an array of **k** values. +A reducer may also be specified as a function to be passed an index of size **k** and the corresponding input channel array; or if the function only takes one argument, an array of **k** values. ## window(*k*) diff --git a/src/options.js b/src/options.js index e05a6e2c5b..eb6e524904 100644 --- a/src/options.js +++ b/src/options.js @@ -211,6 +211,11 @@ export function take(values, index) { return map(index, (i) => values[i]); } +// If f does not take exactly one argument, wraps it in a function that uses take. +export function taker(f) { + return f.length === 1 ? (index, values) => f(take(values, index)) : f; +} + // Based on InternMap (d3.group). export function keyof(value) { return value !== null && typeof value === "object" ? value.valueOf() : value; diff --git a/src/reducer.d.ts b/src/reducer.d.ts index bcba60cd73..03fc739d18 100644 --- a/src/reducer.d.ts +++ b/src/reducer.d.ts @@ -55,7 +55,7 @@ export type ReducerName = * A shorthand functional reducer implementation: given an array of input * channel *values*, returns the corresponding reduced output value. */ -export type ReducerFunction = (values: S[]) => T; +export type ReducerFunction = ((index: number[], values: S[]) => T) | ((values: S[]) => T); /** A reducer implementation. */ export interface ReducerImplementation { diff --git a/src/transforms/normalize.d.ts b/src/transforms/normalize.d.ts index 0f6eb532ce..e4a4273337 100644 --- a/src/transforms/normalize.d.ts +++ b/src/transforms/normalize.d.ts @@ -1,4 +1,4 @@ -import type {ReducerPercentile} from "../reducer.js"; +import type {ReducerFunction, ReducerPercentile} from "../reducer.js"; import type {Transformed} from "./basic.js"; import type {Map} from "./map.js"; @@ -32,7 +32,7 @@ export type NormalizeBasisName = * A functional basis implementation: given an array of input channel *values* * for the current series, returns the corresponding basis number (divisor). */ -export type NormalizeBasisFunction = (values: T[]) => number; +export type NormalizeBasisFunction = ReducerFunction; /** * How to normalize series values; one of: diff --git a/src/transforms/normalize.js b/src/transforms/normalize.js index d5429e9af6..f4d225cd11 100644 --- a/src/transforms/normalize.js +++ b/src/transforms/normalize.js @@ -1,6 +1,6 @@ import {extent, deviation, max, mean, median, min, sum} from "d3"; import {defined} from "../defined.js"; -import {percentile, take} from "../options.js"; +import {percentile, taker} from "../options.js"; import {mapX, mapY} from "./map.js"; export function normalizeX(basis, options) { @@ -15,7 +15,7 @@ export function normalizeY(basis, options) { export function normalize(basis) { if (basis === undefined) return normalizeFirst; - if (typeof basis === "function") return normalizeBasis((I, S) => basis(take(S, I))); + if (typeof basis === "function") return normalizeBasis(taker(basis)); if (/^p\d{2}$/i.test(basis)) return normalizeAccessor(percentile(basis)); switch (`${basis}`.toLowerCase()) { case "deviation": diff --git a/src/transforms/window.js b/src/transforms/window.js index 8b8b5f9411..be733dd8bc 100644 --- a/src/transforms/window.js +++ b/src/transforms/window.js @@ -1,6 +1,6 @@ import {deviation, max, min, median, mode, variance} from "d3"; import {defined} from "../defined.js"; -import {percentile, take} from "../options.js"; +import {percentile, taker} from "../options.js"; import {warn} from "../warnings.js"; import {mapX, mapY} from "./map.js"; @@ -51,24 +51,24 @@ function maybeShift(shift) { function maybeReduce(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 reduceAccessor(percentile(reduce)); switch (reduce.toLowerCase()) { case "deviation": - return reduceNumbers(deviation); + return reduceAccessor(deviation); case "max": - return reduceArray(max); + return reduceArray((I, V) => max(I, (i) => V[i])); case "mean": return reduceMean; case "median": - return reduceNumbers(median); + return reduceAccessor(median); case "min": - return reduceArray(min); + return reduceArray((I, V) => min(I, (i) => V[i])); case "mode": - return reduceArray(mode); + return reduceArray((I, V) => mode(I, (i) => V[i])); case "sum": return reduceSum; case "variance": - return reduceNumbers(variance); + return reduceAccessor(variance); case "difference": return reduceDifference; case "ratio": @@ -80,7 +80,7 @@ function maybeReduce(reduce = "mean") { } } if (typeof reduce !== "function") throw new Error(`invalid reduce: ${reduce}`); - return reduceArray(reduce); + return reduceArray(taker(reduce)); } function slice(I, i, j) { @@ -91,29 +91,29 @@ function slice(I, i, j) { // 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) { +function reduceAccessor(f) { return (k, s, strict) => strict ? { mapIndex(I, S, T) { - const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : S[i])); + const s = (i) => (S[i] == null ? NaN : +S[i]); let nans = 0; - for (let i = 0; i < k - 1; ++i) if (isNaN(C[i])) ++nans; + for (let i = 0; i < k - 1; ++i) if (isNaN(s(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; - if (isNaN(C[i])) --nans; + if (isNaN(s(i + k - 1))) ++nans; + T[I[i + s]] = nans === 0 ? f(slice(I, i, i + k), s) : NaN; + if (isNaN(s(i))) --nans; } } } : { mapIndex(I, S, T) { - const C = Float64Array.from(I, (i) => (S[i] === null ? NaN : S[i])); + const s = (i) => (S[i] == null ? NaN : +S[i]); for (let i = -s; i < 0; ++i) { - T[I[i + s]] = f(C.subarray(0, i + k)); + T[I[i + s]] = f(slice(I, 0, i + k), s); } 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(slice(I, i, i + k), s); } } }; @@ -128,7 +128,7 @@ function reduceArray(f) { for (let i = 0; i < k - 1; ++i) count += defined(S[I[i]]); for (let i = 0, n = I.length - k + 1; i < n; ++i) { count += defined(S[I[i + k - 1]]); - if (count === k) T[I[i + s]] = f(take(S, slice(I, i, i + k))); + if (count === k) T[I[i + s]] = f(slice(I, i, i + k), S); count -= defined(S[I[i]]); } } @@ -136,10 +136,10 @@ function reduceArray(f) { : { mapIndex(I, S, T) { for (let i = -s; i < 0; ++i) { - T[I[i + s]] = f(take(S, slice(I, 0, i + k))); + T[I[i + s]] = f(slice(I, 0, i + k), S); } for (let i = 0, n = I.length - s; i < n; ++i) { - T[I[i + s]] = f(take(S, slice(I, i, i + k))); + T[I[i + s]] = f(slice(I, i, i + k), S); } } }; diff --git a/test/output/aaplCloseNormalize.svg b/test/output/aaplCloseNormalize.svg new file mode 100644 index 0000000000..2fcf086384 --- /dev/null +++ b/test/output/aaplCloseNormalize.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 0.8 + 1.0 + 1.2 + 1.4 + 1.6 + 1.8 + 2.0 + 2.2 + 2.4 + + + ↑ Close + + + + + + + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + \ No newline at end of file diff --git a/test/plots/aapl-close.ts b/test/plots/aapl-close.ts index 0c0b32105e..cb866defc4 100644 --- a/test/plots/aapl-close.ts +++ b/test/plots/aapl-close.ts @@ -49,3 +49,31 @@ export async function aaplCloseGridIterable() { const AAPL = await d3.csv("data/aapl.csv", d3.autoType); return Plot.lineY(AAPL, {x: "Date", y: "Close"}).plot({y: {grid: [100, 120, 140]}}); } + +export async function aaplCloseNormalize() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.plot({ + y: {type: "log", grid: true, tickFormat: ".1f"}, + marks: [ + Plot.ruleY([1]), + Plot.lineY( + aapl, + ((X) => Plot.normalizeY(find(X, new Date("2014-01-01")), {x: X, y: "Close"}))(Plot.valueof(aapl, "Date")) + ) + ] + }); +} + +// Given an array of values X, and a search value x, returns a normalize basis +// method that finds the index of the search value x, and returns the +// corresponding value in Y. +function find(X, x) { + return (I, Y) => { + for (const i of I) { + if (X[i] >= x) { + return Y[i]; + } + } + return Y[I[I.length - 1]]; + }; +} From aabaa271f2b9b3a4f16c578a266d6f5046766a23 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 14 May 2023 21:35:52 -0700 Subject: [PATCH 2/2] simplify --- test/plots/aapl-close.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/test/plots/aapl-close.ts b/test/plots/aapl-close.ts index cb866defc4..f38b815fbb 100644 --- a/test/plots/aapl-close.ts +++ b/test/plots/aapl-close.ts @@ -52,28 +52,16 @@ export async function aaplCloseGridIterable() { export async function aaplCloseNormalize() { const aapl = await d3.csv("data/aapl.csv", d3.autoType); + const x = new Date("2014-01-01"); + const X = Plot.valueof(aapl, "Date"); return Plot.plot({ y: {type: "log", grid: true, tickFormat: ".1f"}, marks: [ Plot.ruleY([1]), Plot.lineY( aapl, - ((X) => Plot.normalizeY(find(X, new Date("2014-01-01")), {x: X, y: "Close"}))(Plot.valueof(aapl, "Date")) + Plot.normalizeY((I, Y) => Y[I.find((i) => X[i] >= x)], {x: X, y: "Close"}) ) ] }); } - -// Given an array of values X, and a search value x, returns a normalize basis -// method that finds the index of the search value x, and returns the -// corresponding value in Y. -function find(X, x) { - return (I, Y) => { - for (const i of I) { - if (X[i] >= x) { - return Y[i]; - } - } - return Y[I[I.length - 1]]; - }; -}