diff --git a/package.json b/package.json index 721b1dbfba..4d01d7f3df 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@types/node": "^20.5.0", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", + "apache-arrow": "^16.0.2", "c8": "^9.1.0", "canvas": "^2.0.0", "d3-geo-projection": "^4.0.0", diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index c92addcff7..f0c0f765b0 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -1,5 +1,6 @@ import {pointer as pointof} from "d3"; import {composeRender} from "../mark.js"; +import {isArray} from "../options.js"; import {applyFrameAnchor} from "../style.js"; const states = new WeakMap(); @@ -126,7 +127,11 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // Dispatch the value. When simultaneously exiting this facet and // entering a new one, prioritize the entering facet. - if (!(i == null && facetState?.size > 1)) context.dispatchValue(i == null ? null : data[i]); + if (!(i == null && facetState?.size > 1)) { + const value = i == null ? null : isArray(data) ? data[i] : data.get(i); + context.dispatchValue(value); + } + return r; } diff --git a/src/mark.d.ts b/src/mark.d.ts index d244fdba76..4e5a60cbed 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -32,6 +32,7 @@ export type TipPointer = "x" | "y" | "xy"; * * - an array, typed array, or other iterable * - an object with a length property and indexed values + * - an Apache Arrow Table */ export type Data = Iterable | ArrayLike; diff --git a/src/mark.js b/src/mark.js index c8c3bca719..21f86d805c 100644 --- a/src/mark.js +++ b/src/mark.js @@ -2,7 +2,7 @@ import {channelDomain, createChannels, valueObject} from "./channel.js"; import {defined} from "./defined.js"; import {maybeFacetAnchor} from "./facet.js"; import {maybeClip, maybeNamed, maybeValue} from "./options.js"; -import {arrayify, isDomainSort, isObject, isOptions, keyword, range, singleton} from "./options.js"; +import {dataify, isDomainSort, isObject, isOptions, keyword, range, singleton} from "./options.js"; import {project} from "./projection.js"; import {maybeClassName, styles} from "./style.js"; import {basic, initializer} from "./transforms/basic.js"; @@ -89,10 +89,10 @@ export class Mark { } } initialize(facets, facetChannels, plotOptions) { - let data = arrayify(this.data); + let data = dataify(this.data); if (facets === undefined && data != null) facets = [range(data)]; const originalFacets = facets; - if (this.transform != null) ({facets, data} = this.transform(data, facets, plotOptions)), (data = arrayify(data)); + if (this.transform != null) ({facets, data} = this.transform(data, facets, plotOptions)), (data = dataify(data)); if (facets !== undefined) facets.original = originalFacets; // needed to read facetChannels const channels = createChannels(this.channels, data); if (this.sort != null) channelDomain(data, facets, channels, facetChannels, this.sort); // mutates facetChannels! diff --git a/src/options.js b/src/options.js index d7abbadf0e..ac9caca472 100644 --- a/src/options.js +++ b/src/options.js @@ -7,6 +7,26 @@ import {timeInterval, utcInterval} from "./time.js"; export const TypedArray = Object.getPrototypeOf(Uint8Array); const objectToString = Object.prototype.toString; +export function isArray(value) { + return value instanceof Array || value instanceof TypedArray; +} + +function isNumberArray(value) { + return value instanceof TypedArray && !isBigIntArray(value); +} + +function isNumberType(type) { + return type?.prototype instanceof TypedArray && !isBigIntType(type); +} + +function isBigIntArray(value) { + return value instanceof BigInt64Array || value instanceof BigUint64Array; +} + +function isBigIntType(type) { + return type === BigInt64Array || type === BigUint64Array; +} + // If a reindex is attached to the data, channel values expressed as arrays will // be reindexed when the channels are instantiated. See exclusiveFacets. export const reindex = Symbol("reindex"); @@ -14,7 +34,9 @@ export const reindex = Symbol("reindex"); export function valueof(data, value, type) { const valueType = typeof value; return valueType === "string" - ? maybeTypedMap(data, field(value), type) + ? isArrowTable(data) + ? maybeTypedArrowify(data.getChild(value), type) + : maybeTypedMap(data, field(value), type) : valueType === "function" ? maybeTypedMap(data, value, type) : valueType === "number" || value instanceof Date || valueType === "boolean" @@ -29,21 +51,25 @@ function maybeTake(values, index) { } function maybeTypedMap(data, f, type) { - return map(data, type?.prototype instanceof TypedArray ? floater(f) : f, type); + return map(data, isNumberType(type) ? (d, i) => coerceNumber(f(d, i)) : f, type); // allow conversion from BigInt } function maybeTypedArrayify(data, type) { return type === undefined ? arrayify(data) // preserve undefined type + : isArrowVector(data) + ? maybeTypedArrowify(data, type) : data instanceof type ? data - : type.prototype instanceof TypedArray && !(data instanceof TypedArray) - ? type.from(data, coerceNumber) - : type.from(data); + : type.from(data, isNumberType(type) && !isNumberArray(data) ? coerceNumber : undefined); } -function floater(f) { - return (d, i) => coerceNumber(f(d, i)); +function maybeTypedArrowify(vector, type) { + return vector == null + ? vector + : (type === undefined || type === Array) && isArrowDateType(vector.type) + ? coerceDates(vector.toArray()) + : maybeTypedArrayify(vector.toArray(), type); } export const singleton = [null]; // for data-less decoration marks, e.g. frame @@ -70,7 +96,7 @@ export function percentile(reduce) { // If the values are specified as a typed array, no coercion is required. export function coerceNumbers(values) { - return values instanceof TypedArray ? values : map(values, coerceNumber, Float64Array); + return isNumberArray(values) ? values : map(values, coerceNumber, Float64Array); } // Unlike Mark’s number, here we want to convert null and undefined to NaN since @@ -95,7 +121,7 @@ export function coerceDate(x) { ? x : typeof x === "string" ? isoParse(x) - : x == null || isNaN((x = +x)) + : x == null || isNaN((x = Number(x))) // allow conversion from BigInt ? undefined : new Date(x); } @@ -130,9 +156,15 @@ export function keyword(input, name, allowed) { return i; } +// Like arrayify, but also allows data to be an Apache Arrow Table. +export function dataify(data) { + return isArrowTable(data) ? data : arrayify(data); +} + // Promotes the specified data to an array as needed. export function arrayify(values) { - if (values == null || values instanceof Array || values instanceof TypedArray) return values; + if (values == null || isArray(values)) return values; + if (isArrowVector(values)) return maybeTypedArrowify(values); switch (values.type) { case "FeatureCollection": return values.features; @@ -233,22 +265,21 @@ export function maybeZ({z, fill, stroke} = {}) { return z; } +export function lengthof(data) { + return isArray(data) ? data.length : data?.numRows; +} + // Returns a Uint32Array with elements [0, 1, 2, … data.length - 1]. export function range(data) { - const n = data.length; + const n = lengthof(data); const r = new Uint32Array(n); for (let i = 0; i < n; ++i) r[i] = i; return r; } -// Returns a filtered range of data given the test function. -export function where(data, test) { - return range(data).filter((i) => test(data[i], i, data)); -} - // Returns an array [values[index[0]], values[index[1]], …]. export function take(values, index) { - return map(index, (i) => values[i], values.constructor); + return isArray(values) ? map(index, (i) => values[i], values.constructor) : map(index, (i) => values.at(i)); } // If f does not take exactly one argument, wraps it in a function that uses take. @@ -575,3 +606,30 @@ export function maybeClip(clip) { else if (clip != null) clip = keyword(clip, "clip", ["frame", "sphere"]); return clip; } + +// https://github.com/observablehq/stdlib/blob/746ca2e69135df6178e4f3a17244def35d8d6b20/src/arrow.js#L4C1-L17C1 +function isArrowTable(value) { + return ( + value && + typeof value.getChild === "function" && + typeof value.toArray === "function" && + value.schema && + Array.isArray(value.schema.fields) + ); +} + +function isArrowVector(value) { + return value && typeof value.toArray === "function" && value.type; +} + +// Apache Arrow now represents dates as numbers. We currently only support +// implicit coercion to JavaScript Date objects when the numbers represent +// milliseconds since Unix epoch. +function isArrowDateType(type) { + return ( + type && + (type.typeId === 8 || // date + type.typeId === 10) && // timestamp + type.unit === 1 // millisecond + ); +} diff --git a/src/plot.js b/src/plot.js index 1e8daf22b8..9fc6a3e138 100644 --- a/src/plot.js +++ b/src/plot.js @@ -10,7 +10,7 @@ import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./mark import {frame} from "./marks/frame.js"; import {tip} from "./marks/tip.js"; import {isColor, isIterable, isNone, isScaleOptions} from "./options.js"; -import {arrayify, map, yes, maybeIntervalTransform, subarray} from "./options.js"; +import {dataify, lengthof, map, yes, maybeIntervalTransform, subarray} from "./options.js"; import {createProjection, getGeometryChannels, hasProjection} from "./projection.js"; import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; @@ -459,7 +459,7 @@ function maybeTopFacet(facet, options) { if (facet == null) return; const {x, y} = facet; if (x == null && y == null) return; - const data = arrayify(facet.data); + const data = dataify(facet.data); if (data == null) throw new Error("missing facet data"); const channels = {}; if (x != null) channels.fx = createChannel(data, {value: x, scale: "fx"}); @@ -478,7 +478,7 @@ function maybeMarkFacet(mark, topFacetState, options) { // here with maybeTopFacet that we could reduce. const {fx, fy} = mark; if (fx != null || fy != null) { - const data = arrayify(mark.data ?? fx ?? fy); + const data = dataify(mark.data ?? fx ?? fy); if (data === undefined) throw new Error(`missing facet data in ${mark.ariaLabel}`); if (data === null) return; // ignore channel definitions if no data is provided TODO this right? const channels = {}; @@ -500,7 +500,7 @@ function maybeMarkFacet(mark, topFacetState, options) { if ( data.length > 0 && (groups.size > 1 || (groups.size === 1 && channels.fx && channels.fy && [...groups][0][1].size > 1)) && - arrayify(mark.data)?.length === data.length + lengthof(dataify(mark.data)) === lengthof(data) ) { warn( `Warning: the ${mark.ariaLabel} mark appears to use faceted data, but isn’t faceted. The mark data has the same length as the facet data and the mark facet option is "auto", but the mark data and facet data are distinct. If this mark should be faceted, set the mark facet option to true; otherwise, suppress this warning by setting the mark facet option to false.` diff --git a/src/transforms/basic.js b/src/transforms/basic.js index 573032fa47..8146ae9381 100644 --- a/src/transforms/basic.js +++ b/src/transforms/basic.js @@ -1,6 +1,7 @@ import {randomLcg} from "d3"; import {ascendingDefined, descendingDefined} from "../defined.js"; -import {arrayify, isDomainSort, isOptions, maybeValue, valueof} from "../options.js"; +import {isArray, isDomainSort, isOptions} from "../options.js"; +import {dataify, maybeValue, valueof} from "../options.js"; export function basic({filter: f1, sort: s1, reverse: r1, transform: t1, initializer: i1, ...options} = {}, transform) { // If both t1 and t2 are defined, returns a composite transform that first @@ -40,7 +41,7 @@ function composeTransform(t1, t2) { if (t2 == null) return t1 === null ? undefined : t1; return function (data, facets, plotOptions) { ({data, facets} = t1.call(this, data, facets, plotOptions)); - return t2.call(this, arrayify(data), facets, plotOptions); + return t2.call(this, dataify(data), facets, plotOptions); }; } @@ -101,7 +102,9 @@ function sortTransform(value) { function sortData(compare) { return (data, facets) => { - const compareData = (i, j) => compare(data[i], data[j]); + const compareData = isArray(data) + ? (i, j) => compare(data[i], data[j]) + : (i, j) => compare(data.get(i), data.get(j)); return {data, facets: facets.map((I) => I.slice().sort(compareData))}; }; } diff --git a/src/transforms/exclusiveFacets.js b/src/transforms/exclusiveFacets.js index facf94bfb8..3a560a065e 100644 --- a/src/transforms/exclusiveFacets.js +++ b/src/transforms/exclusiveFacets.js @@ -1,9 +1,9 @@ -import {reindex, slice} from "../options.js"; +import {lengthof, reindex, slice} from "../options.js"; export function exclusiveFacets(data, facets) { if (facets.length === 1) return {data, facets}; // only one facet; trivially exclusive - const n = data.length; + const n = lengthof(data); const O = new Uint8Array(n); let overlaps = 0; diff --git a/src/transforms/group.js b/src/transforms/group.js index 07ae348359..b3320efd65 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -1,37 +1,9 @@ -import { - InternSet, - deviation, - group as grouper, - max, - maxIndex, - mean, - median, - min, - minIndex, - mode, - rollup, - sort, - sum, - variance -} from "d3"; +import {InternSet, group as grouper, rollup, sort} from "d3"; +import {deviation, max, maxIndex, mean, median, min, minIndex, mode, sum, variance} from "d3"; import {ascendingDefined} from "../defined.js"; -import { - column, - identity, - isObject, - isTemporal, - labelof, - maybeApplyInterval, - maybeColorChannel, - maybeColumn, - maybeInput, - maybeTuple, - percentile, - range, - second, - take, - valueof -} from "../options.js"; +import {maybeApplyInterval, maybeColorChannel, maybeColumn, maybeInput, maybeTuple} from "../options.js"; +import {isArray, isObject, isTemporal} from "../options.js"; +import {column, identity, labelof, percentile, range, second, take, valueof} from "../options.js"; import {basic} from "./basic.js"; // Group on {z, fill, stroke}. @@ -444,7 +416,7 @@ export function find(test) { if (typeof test !== "function") throw new Error(`invalid test function: ${test}`); return { reduceIndex(I, V, {data}) { - return V[I.find((i) => test(data[i], i, data))]; + return V[I.find(isArray(data) ? (i) => test(data[i], i, data) : (i) => test(data.get(i), i, data))]; } }; } diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 9c96997af0..20d7342e03 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -2,7 +2,7 @@ import {InternMap, cumsum, greatest, group, groupSort, max, min, rollup, sum} fr import {ascendingDefined, descendingDefined} from "../defined.js"; import {withTip} from "../mark.js"; import {maybeApplyInterval, maybeColumn, maybeZ, maybeZero} from "../options.js"; -import {column, field, mid, one, range, valueof} from "../options.js"; +import {column, field, isArray, lengthof, mid, one, range, valueof} from "../options.js"; import {basic} from "./basic.js"; import {exclusiveFacets} from "./exclusiveFacets.js"; @@ -91,7 +91,7 @@ function stack(x, y = one, kx, ky, {offset, order, reverse}, options) { const Y = valueof(data, y, Float64Array); const Z = valueof(data, z); const compare = order && order(data, X, Y, Z); - const n = data.length; + const n = lengthof(data); const Y1 = setY1(new Float64Array(n)); const Y2 = setY2(new Float64Array(n)); const facetstacks = []; @@ -252,7 +252,7 @@ function maybeOrder(order, offset, ky) { return orderAccessor(field(order)); } if (typeof order === "function") return (order.length === 1 ? orderAccessor : orderComparator)(order); - if (Array.isArray(order)) return orderGiven(order); + if (isArray(order)) return orderGiven(order); throw new Error(`invalid order: ${order}`); } @@ -327,7 +327,9 @@ function orderAccessor(f) { } function orderComparator(f) { - return (data) => (i, j) => f(data[i], data[j]); + return (data) => { + return isArray(data) ? (i, j) => f(data[i], data[j]) : (i, j) => f(data.get(i), data.get(j)); + }; } function orderGiven(domain) { diff --git a/src/transforms/tree.js b/src/transforms/tree.js index 067fc03229..2362e19f05 100644 --- a/src/transforms/tree.js +++ b/src/transforms/tree.js @@ -1,6 +1,6 @@ import {stratify, tree} from "d3"; import {ascendingDefined} from "../defined.js"; -import {column, identity, isObject, one, valueof} from "../options.js"; +import {column, identity, isArray, isObject, one, valueof} from "../options.js"; import {basic} from "./basic.js"; export function treeNode({ @@ -34,13 +34,16 @@ export function treeNode({ const treeData = []; const treeFacets = []; const rootof = stratify().path((i) => P[i]); + const setData = isArray(data) + ? (node) => (node.data = data[node.data]) + : (node) => (node.data = data.get(node.data)); const layout = treeLayout(); if (layout.nodeSize) layout.nodeSize([1, 1]); if (layout.separation && treeSeparation !== undefined) layout.separation(treeSeparation ?? one); for (const o of outputs) o[output_values] = o[output_setValues]([]); for (const facet of facets) { const treeFacet = []; - const root = rootof(facet.filter((i) => P[i] != null)).each((node) => (node.data = data[node.data])); + const root = rootof(facet.filter((i) => P[i] != null)).each(setData); if (treeSort != null) root.sort(treeSort); layout(root); for (const node of root.descendants()) { diff --git a/test/output/arrowDates.svg b/test/output/arrowDates.svg new file mode 100644 index 0000000000..de1e69765f --- /dev/null +++ b/test/output/arrowDates.svg @@ -0,0 +1,121 @@ + + + + + 0 + 100 + 200 + 300 + 400 + 500 + 600 + 700 + 800 + 900 + + + ↑ Frequency + + + + 1955 + 1960 + 1965 + 1970 + 1975 + 1980 + 1985 + 1990 + 1995 + 2000 + + + date_of_birth → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTest.svg b/test/output/arrowTest.svg new file mode 100644 index 0000000000..acc3317162 --- /dev/null +++ b/test/output/arrowTest.svg @@ -0,0 +1,61 @@ + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + + + ↑ age + + + + Alice + Bob + Charlie + + + name + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTestAccessor.svg b/test/output/arrowTestAccessor.svg new file mode 100644 index 0000000000..3fb14f8d58 --- /dev/null +++ b/test/output/arrowTestAccessor.svg @@ -0,0 +1,61 @@ + + + + + 0 + 5 + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + + + ↑ age + + + + Alice + Bob + Charlie + + + name + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTestBin.svg b/test/output/arrowTestBin.svg new file mode 100644 index 0000000000..4d43d4dc7b --- /dev/null +++ b/test/output/arrowTestBin.svg @@ -0,0 +1,116 @@ + + + + + 0 + 5,000 + 10,000 + 15,000 + 20,000 + 25,000 + 30,000 + 35,000 + 40,000 + 45,000 + 50,000 + 55,000 + 60,000 + + + ↑ Frequency + + + + 0 + 2 + 4 + 6 + 8 + 10 + 12 + + + vector → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTestCustomOrder.svg b/test/output/arrowTestCustomOrder.svg new file mode 100644 index 0000000000..0729686f1d --- /dev/null +++ b/test/output/arrowTestCustomOrder.svg @@ -0,0 +1,134 @@ + + + + + + 0 + 2 + 4 + 6 + 8 + 10 + 12 + 14 + 16 + 18 + 20 + 22 + + + ↑ Annual revenue (billions, adj.) + + + + 1975 + 1980 + 1985 + 1990 + 1995 + 2000 + 2005 + 2010 + 2015 + + + 8 - Track + Tape + CD + Disc + CD Single + Disc + Cassette + Tape + Cassette Single + Tape + DVD Audio + Other + Download Album + Download + Download Music Video + Download + Download Single + Download + Kiosk + Other + LP/EP + Vinyl + Limited Tier Paid Subscription + Streaming + Music Video (Physical) + Other + On-Demand Streaming (Ad-Supported) + Streaming + Other Ad-Supported Streaming + Streaming + Other Digital + Download + Other Tapes + Tape + Paid Subscription + Streaming + Ringtones & Ringbacks + Download + SACD + Disc + SoundExchange Distributions + Streaming + Synchronization + Other + Vinyl Single + Vinyl + + + + + \ No newline at end of file diff --git a/test/output/arrowTestDifferenceY.svg b/test/output/arrowTestDifferenceY.svg new file mode 100644 index 0000000000..0ae2fbf577 --- /dev/null +++ b/test/output/arrowTestDifferenceY.svg @@ -0,0 +1,78 @@ + + + + + 1.0 + 1.2 + 1.4 + 1.6 + 1.8 + 2.0 + 2.2 + 2.4 + 2.6 + 2.8 + + + ↑ Close + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTestGroup.svg b/test/output/arrowTestGroup.svg new file mode 100644 index 0000000000..77e741032b --- /dev/null +++ b/test/output/arrowTestGroup.svg @@ -0,0 +1,126 @@ + + + + + 0 + 5,000 + 10,000 + 15,000 + 20,000 + 25,000 + 30,000 + 35,000 + 40,000 + 45,000 + 50,000 + 55,000 + 60,000 + + + ↑ Frequency + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + + + vector + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTestPointer.html b/test/output/arrowTestPointer.html new file mode 100644 index 0000000000..d91b22d848 --- /dev/null +++ b/test/output/arrowTestPointer.html @@ -0,0 +1,403 @@ +
+ + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + ↑ culmen_depth_mm + + + + 35 + 40 + 45 + 50 + 55 + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/arrowTestSort.svg b/test/output/arrowTestSort.svg new file mode 100644 index 0000000000..2dd19e56c0 --- /dev/null +++ b/test/output/arrowTestSort.svg @@ -0,0 +1,50 @@ + + + + + 0 + 10 + 20 + 30 + 40 + 50 + 60 + 70 + 80 + 90 + 100 + + + age → + + + + + + + \ No newline at end of file diff --git a/test/output/arrowTestTree.svg b/test/output/arrowTestTree.svg new file mode 100644 index 0000000000..2ad47dc9c1 --- /dev/null +++ b/test/output/arrowTestTree.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + /Chaos + /Chaos/Eros + /Chaos/Erebus + /Chaos/Tartarus + /Chaos/Gaia + /Chaos/Gaia/Mountains + /Chaos/Gaia/Pontus + /Chaos/Gaia/Uranus + + + Eros/Chaos/Eros + Erebus/Chaos/Erebus + Tartarus/Chaos/Tartarus + Mountains/Chaos/Gaia/Mountains + Pontus/Chaos/Gaia/Pontus + Uranus/Chaos/Gaia/Uranus + + + Chaos/Chaos + Gaia/Chaos/Gaia + + \ No newline at end of file diff --git a/test/plots/arrow-dates.ts b/test/plots/arrow-dates.ts new file mode 100644 index 0000000000..52bb49b221 --- /dev/null +++ b/test/plots/arrow-dates.ts @@ -0,0 +1,9 @@ +import * as Plot from "@observablehq/plot"; +import * as Arrow from "apache-arrow"; +import * as d3 from "d3"; + +export async function arrowDates() { + const athletes = await d3.csv("data/athletes.csv", d3.autoType); + const table = Arrow.tableFromJSON(athletes); + return Plot.rectY(table, Plot.binX(undefined, {x: "date_of_birth"})).plot(); +} diff --git a/test/plots/arrow.ts b/test/plots/arrow.ts new file mode 100644 index 0000000000..19ad4ca28b --- /dev/null +++ b/test/plots/arrow.ts @@ -0,0 +1,163 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import * as Arrow from "apache-arrow"; +import {html} from "htl"; + +/** + * An arrow table dataset supports direct (getChild) accessors. + */ +export async function arrowTest() { + const data = Arrow.tableFromArrays({ + id: [1, 2, 3], + name: ["Alice", "Bob", "Charlie"], + age: [35, 25, 45] + }); + return Plot.barY(data, {x: "name", y: "age"}).plot(); +} + +/** + * An arrow table dataset supports function accessors. + */ +export async function arrowTestAccessor() { + const data = Arrow.tableFromArrays({ + id: [1, 2, 3], + name: ["Alice", "Bob", "Charlie"], + age: [35, 25, 45] + }); + + return Plot.barY(data, {x: "name", y: "age", fill: (d) => d.name}).plot(); +} + +/** + * An arrow table dataset supports binning. + */ +export async function arrowTestBin() { + const seed = d3.randomLcg(42); + const vector = Uint8Array.from({length: 1e5}, d3.randomExponential.source(seed)(1)); + const category = Array.from({length: 1e5}, d3.randomInt.source(seed)(4)).map((i) => `a${i}`); + const data = Arrow.tableFromArrays({category, vector}); + return Plot.rectY(data, Plot.binX({y: "count"}, {x: "vector", fill: "category", thresholds: 10})).plot({ + marginLeft: 60 + }); +} + +/** + * An arrow table dataset supports grouping. + */ +export async function arrowTestGroup() { + const seed = d3.randomLcg(42); + const vector = Uint8Array.from({length: 1e5}, d3.randomExponential.source(seed)(1)); + const category = Array.from({length: 1e5}, d3.randomInt.source(seed)(4)).map((i) => `a${i}`); + const data = Arrow.tableFromArrays({category, vector}); + return Plot.barY(data, Plot.groupX({y: "count"}, {x: "vector", fill: "category"})).plot({marginLeft: 60}); +} + +/** + * An arrow table dataset supports sorting with a comparator. + */ +export async function arrowTestSort() { + const data = Arrow.tableFromArrays({ + id: [1, 2, 3], + name: ["Alice", "Bob", "Charlie"], + age: [35, 25, 45] + }); + return Plot.barX(data, {x: "age", fill: "name", sort: (a: {age: number}, b: {age: number}) => b.age - a.age}).plot(); +} + +/** + * An arrow table dataset supports accessing the node's datum. + */ +export async function arrowTestTree() { + const gods = Arrow.tableFromArrays({ + branch: `Chaos Gaia Mountains +Chaos Gaia Pontus +Chaos Gaia Uranus +Chaos Eros +Chaos Erebus +Chaos Tartarus` + .split("\n") + .map((d) => d.replace(/\s+/g, "/")) + }); + return Plot.plot({ + axis: null, + insetLeft: 35, + insetTop: 20, + insetBottom: 20, + insetRight: 120, + marks: [Plot.tree(gods, {path: "branch", fill: (d) => d?.branch})] + }); +} + +/** + * An arrow table dataset supports Plot.find. + */ +export async function arrowTestDifferenceY() { + const stocks = Arrow.tableFromJSON(await readStocks()); + return Plot.plot({ + marks: [ + Plot.differenceY( + stocks, + Plot.normalizeY( + Plot.groupX( + {y1: Plot.find((d) => d.Symbol === "GOOG"), y2: Plot.find((d) => d.Symbol === "AAPL")}, + {x: "Date", y: "Close", tip: true} + ) + ) + ) + ] + }); +} + +async function readStocks(start = 0, end = Infinity) { + return ( + await Promise.all( + ["AAPL", "GOOG"].map((symbol) => + d3.csv(`data/${symbol.toLowerCase()}.csv`, (d, i) => + start <= i && i < end ? ((d.Symbol = symbol), d3.autoType(d)) : null + ) + ) + ) + ).flat(); +} + +/** + * An arrow table dataset supports stack custom order. + */ +export async function arrowTestCustomOrder() { + const riaa = Arrow.tableFromJSON(await d3.csv("data/riaa-us-revenue.csv", d3.autoType)); + return Plot.plot({ + y: { + grid: true, + label: "Annual revenue (billions, adj.)", + transform: (d) => d / 1000 + }, + marks: [ + Plot.areaY( + riaa, + Plot.stackY({ + x: "year", + y: "revenue", + z: "format", + order: (a, b) => d3.ascending(a.group, b.group) || d3.descending(a.revenue, b.revenue), + fill: "group", + stroke: "white", + title: (d) => `${d.format}\n${d.group}` + }) + ), + Plot.ruleY([0]) + ] + }); +} + +/** + * An arrow table dataset works with the pointer. + */ +export async function arrowTestPointer() { + const penguins = Arrow.tableFromJSON(await d3.csv("data/penguins.csv", d3.autoType)); + const plot = Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", tip: true}).plot(); + const textarea = html`