|
| 1 | +import {InternSet} from "d3"; |
| 2 | +import {isOrdinal, labelof, valueof, isOptions, isColor} from "../options.js"; |
| 3 | +import {area, areaX, areaY} from "./area.js"; |
| 4 | +import {dot} from "./dot.js"; |
| 5 | +import {line, lineX, lineY} from "./line.js"; |
| 6 | +import {ruleX, ruleY} from "./rule.js"; |
| 7 | +import {barX, barY} from "./bar.js"; |
| 8 | +import {rect, rectX, rectY} from "./rect.js"; |
| 9 | +import {cell} from "./cell.js"; |
| 10 | +import {frame} from "./frame.js"; |
| 11 | +import {bin, binX, binY} from "../transforms/bin.js"; |
| 12 | +import {group, groupX, groupY} from "../transforms/group.js"; |
| 13 | +import {marks} from "../mark.js"; |
| 14 | +import {ascending} from "d3"; |
| 15 | + |
| 16 | +export function auto(data, {x, y, color, size, fx, fy, mark} = {}) { |
| 17 | + // Allow x and y and other dimensions to be specified as shorthand field names |
| 18 | + // (but note that they can also be specified as a {transform} object such as |
| 19 | + // Plot.identity). |
| 20 | + if (!isOptions(x)) x = makeOptions(x); |
| 21 | + if (!isOptions(y)) y = makeOptions(y); |
| 22 | + if (!isOptions(color)) color = isColor(color) ? {color} : makeOptions(color); |
| 23 | + if (!isOptions(size)) size = makeOptions(size); |
| 24 | + |
| 25 | + // We don’t apply any type inference to the fx and fy channels, if present, so |
| 26 | + // these are simply passed-through to the underlying mark’s options. |
| 27 | + if (isOptions(fx)) ({value: fx} = makeOptions(fx)); |
| 28 | + if (isOptions(fy)) ({value: fy} = makeOptions(fy)); |
| 29 | + |
| 30 | + const {value: xValue} = x; |
| 31 | + const {value: yValue} = y; |
| 32 | + const {value: sizeValue} = size; |
| 33 | + const {value: colorValue, color: colorColor} = color; |
| 34 | + |
| 35 | + // Determine the default reducer, if any. |
| 36 | + let {reduce: xReduce} = x; |
| 37 | + let {reduce: yReduce} = y; |
| 38 | + let {reduce: sizeReduce} = size; |
| 39 | + let {reduce: colorReduce} = color; |
| 40 | + if (xReduce === undefined) |
| 41 | + xReduce = yReduce == null && xValue == null && sizeValue == null && yValue != null ? "count" : null; |
| 42 | + if (yReduce === undefined) |
| 43 | + yReduce = xReduce == null && yValue == null && sizeValue == null && xValue != null ? "count" : null; |
| 44 | + |
| 45 | + let {zero: xZero} = x; |
| 46 | + let {zero: yZero} = y; |
| 47 | + |
| 48 | + // TODO The line mark will need z? |
| 49 | + // TODO Limit and sort for bar charts (e.g. alphabet)? |
| 50 | + // TODO Look at Plot warnings and see how many we can prevent |
| 51 | + // TODO Default to something other than turbo for continuous? Like: |
| 52 | + // scheme: (colorValue && isContinuous(color)) || colorReduce ? "ylgnbu" : undefined |
| 53 | + |
| 54 | + // To apply heuristics based on the data types (values), realize the columns. |
| 55 | + // We could maybe look at the data.schema here, but Plot’s behavior depends on |
| 56 | + // the actual values anyway, so this probably is what we want. By |
| 57 | + // materializing the columns here, we also ensure that they aren’t re-computed |
| 58 | + // later in Plot.plot. |
| 59 | + x = valueof(data, xValue); |
| 60 | + y = valueof(data, yValue); |
| 61 | + color = valueof(data, colorValue); |
| 62 | + size = valueof(data, sizeValue); |
| 63 | + |
| 64 | + // TODO Shorthand: array of primitives should result in a histogram |
| 65 | + if (!x && !y) throw new Error("must specify x or y"); |
| 66 | + if (xReduce != null && !y) throw new Error("reducing x requires y"); |
| 67 | + if (yReduce != null && !x) throw new Error("reducing y requires x"); |
| 68 | + |
| 69 | + let z, zReduce; |
| 70 | + |
| 71 | + // Propagate the x and y labels (field names), if any. This is necessary for |
| 72 | + // any column we materialize (and hence, we don’t need to do this for fx and |
| 73 | + // fy, since those columns are not needed for type inference and hence are not |
| 74 | + // greedily materialized). |
| 75 | + if (x) x.label = labelof(xValue); |
| 76 | + if (y) y.label = labelof(yValue); |
| 77 | + if (color) color.label = labelof(colorValue); |
| 78 | + if (size) size.label = labelof(sizeValue); |
| 79 | + |
| 80 | + // Determine the default size reducer, if any. |
| 81 | + if ( |
| 82 | + sizeReduce === undefined && |
| 83 | + sizeValue == null && |
| 84 | + colorReduce == null && |
| 85 | + xReduce == null && |
| 86 | + yReduce == null && |
| 87 | + (!x || isOrdinal(x)) && |
| 88 | + (!y || isOrdinal(y)) |
| 89 | + ) { |
| 90 | + sizeReduce = "count"; |
| 91 | + } |
| 92 | + |
| 93 | + // Determine the default mark type. |
| 94 | + if (mark === undefined) { |
| 95 | + mark = |
| 96 | + sizeValue != null || sizeReduce != null |
| 97 | + ? "dot" |
| 98 | + : xReduce != null || yReduce != null || colorReduce != null |
| 99 | + ? "bar" |
| 100 | + : x && y |
| 101 | + ? isContinuous(x) && isContinuous(y) && (isMonotonic(x) || isMonotonic(y)) |
| 102 | + ? "line" |
| 103 | + : (isContinuous(x) && xZero) || (isContinuous(y) && yZero) |
| 104 | + ? "bar" |
| 105 | + : "dot" |
| 106 | + : x |
| 107 | + ? "rule" |
| 108 | + : y |
| 109 | + ? "rule" |
| 110 | + : null; |
| 111 | + } |
| 112 | + |
| 113 | + let colorMode; // "fill" or "stroke" |
| 114 | + |
| 115 | + // Determine the mark implementation. |
| 116 | + if (mark != null) { |
| 117 | + switch (`${mark}`.toLowerCase()) { |
| 118 | + case "dot": |
| 119 | + mark = dot; |
| 120 | + colorMode = "stroke"; |
| 121 | + break; |
| 122 | + case "line": |
| 123 | + mark = x && y ? line : x ? lineX : lineY; // 1d line by index |
| 124 | + colorMode = "stroke"; |
| 125 | + if (isHighCardinality(color)) z = null; // TODO only if z not set by user |
| 126 | + break; |
| 127 | + case "area": |
| 128 | + mark = |
| 129 | + x && y |
| 130 | + ? isContinuous(x) && isMonotonic(x) |
| 131 | + ? areaY |
| 132 | + : isContinuous(y) && isMonotonic(y) |
| 133 | + ? areaX |
| 134 | + : area // TODO error? how does it work with ordinal? |
| 135 | + : x |
| 136 | + ? areaX |
| 137 | + : areaY; // 1d area by index |
| 138 | + colorMode = "fill"; |
| 139 | + if (isHighCardinality(color)) z = null; // TODO only if z not set by user |
| 140 | + break; |
| 141 | + case "rule": |
| 142 | + mark = x ? ruleX : ruleY; |
| 143 | + colorMode = "stroke"; |
| 144 | + break; |
| 145 | + case "bar": |
| 146 | + mark = |
| 147 | + yReduce != null |
| 148 | + ? isOrdinal(x) |
| 149 | + ? barY |
| 150 | + : rectY |
| 151 | + : xReduce != null |
| 152 | + ? isOrdinal(y) |
| 153 | + ? barX |
| 154 | + : rectX |
| 155 | + : colorReduce != null |
| 156 | + ? x && y && isOrdinal(x) && isOrdinal(y) |
| 157 | + ? cell |
| 158 | + : x && isOrdinal(x) |
| 159 | + ? barY |
| 160 | + : y && isOrdinal(y) |
| 161 | + ? barX |
| 162 | + : rect |
| 163 | + : x && y && isOrdinal(x) && isOrdinal(y) |
| 164 | + ? cell |
| 165 | + : y && isOrdinal(y) |
| 166 | + ? barX |
| 167 | + : barY; |
| 168 | + colorMode = "fill"; |
| 169 | + break; |
| 170 | + default: |
| 171 | + throw new Error(`invalid mark: ${mark}`); |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + // Determine the mark options. |
| 176 | + let options = {x, y, [colorMode]: color ?? colorColor, z, r: size, fx, fy}; |
| 177 | + let transform; |
| 178 | + let transformOptions = {[colorMode]: colorReduce, z: zReduce, r: sizeReduce}; |
| 179 | + if (xReduce != null && yReduce != null) { |
| 180 | + throw new Error(`cannot reduce both x and y`); // for now at least |
| 181 | + } else if (yReduce != null) { |
| 182 | + transformOptions.y = yReduce; |
| 183 | + transform = isOrdinal(x) ? groupX : binX; |
| 184 | + } else if (xReduce != null) { |
| 185 | + transformOptions.x = xReduce; |
| 186 | + transform = isOrdinal(y) ? groupY : binY; |
| 187 | + } else if (colorReduce != null || sizeReduce != null) { |
| 188 | + if (x && y) { |
| 189 | + transform = isOrdinal(x) && isOrdinal(y) ? group : isOrdinal(x) ? binY : isOrdinal(y) ? binX : bin; |
| 190 | + } else if (x) { |
| 191 | + transform = isOrdinal(x) ? groupX : binX; |
| 192 | + } else if (y) { |
| 193 | + transform = isOrdinal(y) ? groupY : binY; |
| 194 | + } |
| 195 | + } |
| 196 | + if (transform) options = transform(transformOptions, options); |
| 197 | + |
| 198 | + // If zero-ness is not specified, default based on whether the resolved mark |
| 199 | + // type will include a zero baseline. |
| 200 | + if (xZero === undefined) xZero = transform !== binX && (mark === barX || mark === areaX || mark === rectX); |
| 201 | + if (yZero === undefined) yZero = transform !== binY && (mark === barY || mark === areaY || mark === rectY); |
| 202 | + |
| 203 | + // In the case of filled marks (particularly bars and areas) the frame and |
| 204 | + // rules should come after the mark; in the case of stroked marks |
| 205 | + // (particularly dots and lines) they should come before the mark. |
| 206 | + const frames = fx != null || fy != null ? frame({strokeOpacity: 0.1}) : null; |
| 207 | + const rules = [xZero ? ruleX([0]) : null, yZero ? ruleY([0]) : null]; |
| 208 | + mark = mark(data, options); |
| 209 | + return colorMode === "stroke" ? marks(frames, rules, mark) : marks(frames, mark, rules); |
| 210 | +} |
| 211 | + |
| 212 | +function isContinuous(values) { |
| 213 | + return !isOrdinal(values); |
| 214 | +} |
| 215 | + |
| 216 | +// TODO What about sorted within series? |
| 217 | +function isMonotonic(values) { |
| 218 | + let previous; |
| 219 | + let previousOrder; |
| 220 | + for (const value of values) { |
| 221 | + if (value == null) continue; |
| 222 | + if (previous === undefined) { |
| 223 | + previous = value; |
| 224 | + continue; |
| 225 | + } |
| 226 | + const order = Math.sign(ascending(previous, value)); |
| 227 | + if (!order) continue; // skip zero, NaN |
| 228 | + if (previousOrder !== undefined && order !== previousOrder) return false; |
| 229 | + previous = value; |
| 230 | + previousOrder = order; |
| 231 | + } |
| 232 | + return true; |
| 233 | +} |
| 234 | + |
| 235 | +function makeOptions(value) { |
| 236 | + return isReducer(value) ? {reduce: value} : {value}; |
| 237 | +} |
| 238 | + |
| 239 | +// https://github.com/observablehq/plot/blob/818562649280e155136f730fc496e0b3d15ae464/src/transforms/group.js#L236 |
| 240 | +function isReducer(reduce) { |
| 241 | + if (typeof reduce?.reduce === "function") return true; |
| 242 | + if (/^p\d{2}$/i.test(reduce)) return true; |
| 243 | + switch (`${reduce}`.toLowerCase()) { |
| 244 | + case "first": |
| 245 | + case "last": |
| 246 | + case "count": |
| 247 | + case "distinct": |
| 248 | + case "sum": |
| 249 | + case "proportion": |
| 250 | + case "proportion-facet": // TODO remove me? |
| 251 | + case "deviation": |
| 252 | + case "min": |
| 253 | + case "min-index": // TODO remove me? |
| 254 | + case "max": |
| 255 | + case "max-index": // TODO remove me? |
| 256 | + case "mean": |
| 257 | + case "median": |
| 258 | + case "variance": |
| 259 | + case "mode": |
| 260 | + // These are technically reducers, but I think we’d want to treat them as fields? |
| 261 | + // case "x": |
| 262 | + // case "x1": |
| 263 | + // case "x2": |
| 264 | + // case "y": |
| 265 | + // case "y1": |
| 266 | + // case "y2": |
| 267 | + return true; |
| 268 | + } |
| 269 | + return false; |
| 270 | +} |
| 271 | + |
| 272 | +function isHighCardinality(value) { |
| 273 | + return value ? new InternSet(value).size > value.length >> 1 : false; |
| 274 | +} |
0 commit comments