waffle.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {extent, namespaces} from "d3";
  2. import {valueObject} from "../channel.js";
  3. import {create} from "../context.js";
  4. import {composeRender} from "../mark.js";
  5. import {hasXY, identity, indexOf, isObject} from "../options.js";
  6. import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, getPatternId} from "../style.js";
  7. import {template} from "../template.js";
  8. import {initializer} from "../transforms/basic.js";
  9. import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
  10. import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
  11. import {maybeStackX, maybeStackY} from "../transforms/stack.js";
  12. import {BarX, BarY} from "./bar.js";
  13. const waffleDefaults = {
  14. ariaLabel: "waffle"
  15. };
  16. export class WaffleX extends BarX {
  17. constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
  18. super(data, wafflePolygon("x", options), waffleDefaults);
  19. this.unit = Math.max(0, unit);
  20. this.gap = +gap;
  21. this.round = maybeRound(round);
  22. this.multiple = maybeMultiple(multiple);
  23. }
  24. }
  25. export class WaffleY extends BarY {
  26. constructor(data, {unit = 1, gap = 1, round, multiple, ...options} = {}) {
  27. super(data, wafflePolygon("y", options), waffleDefaults);
  28. this.unit = Math.max(0, unit);
  29. this.gap = +gap;
  30. this.round = maybeRound(round);
  31. this.multiple = maybeMultiple(multiple);
  32. }
  33. }
  34. function wafflePolygon(y, options) {
  35. const x = y === "y" ? "x" : "y";
  36. const y1 = `${y}1`;
  37. const y2 = `${y}2`;
  38. return initializer(waffleRender(options), function (data, facets, channels, scales, dimensions) {
  39. const {round, unit} = this;
  40. const Y1 = channels[y1].value;
  41. const Y2 = channels[y2].value;
  42. // We might not use all the available bandwidth if the cells don’t fit evenly.
  43. const xy = valueObject({...(x in channels && {[x]: channels[x]}), [y1]: channels[y1], [y2]: channels[y2]}, scales);
  44. const barwidth = this[y === "y" ? "_width" : "_height"](scales, xy, dimensions);
  45. const barx = this[y === "y" ? "_x" : "_y"](scales, xy, dimensions);
  46. // The length of a unit along y in pixels.
  47. const scale = unit * scaleof(scales.scales[y]);
  48. // The number of cells on each row (or column) of the waffle.
  49. const {multiple = Math.max(1, Math.floor(Math.sqrt(barwidth / scale)))} = this;
  50. // The outer size of each square cell, in pixels, including the gap.
  51. const cx = Math.min(barwidth / multiple, scale * multiple);
  52. const cy = scale * multiple;
  53. // The reference position.
  54. const tx = (barwidth - multiple * cx) / 2;
  55. const x0 = typeof barx === "function" ? (i) => barx(i) + tx : barx + tx;
  56. const y0 = scales[y](0);
  57. // TODO insets?
  58. const transform = y === "y" ? ([x, y]) => [x * cx, -y * cy] : ([x, y]) => [y * cy, x * cx];
  59. const mx = typeof x0 === "function" ? (i) => x0(i) - barwidth / 2 : () => x0;
  60. const [ix, iy] = y === "y" ? [0, 1] : [1, 0];
  61. const n = Y2.length;
  62. const P = new Array(n);
  63. const X = new Float64Array(n);
  64. const Y = new Float64Array(n);
  65. for (let i = 0; i < n; ++i) {
  66. P[i] = wafflePoints(round(Y1[i] / unit), round(Y2[i] / unit), multiple).map(transform);
  67. const c = P[i].pop(); // extract the transformed centroid
  68. X[i] = c[ix] + mx(i);
  69. Y[i] = c[iy] + y0;
  70. }
  71. return {
  72. channels: {
  73. polygon: {value: P, source: null, filter: null},
  74. [`c${x}`]: {value: [cx, x0], source: null, filter: null},
  75. [`c${y}`]: {value: [cy, y0], source: null, filter: null},
  76. [x]: {value: X, scale: null, source: null},
  77. [y1]: {value: Y, scale: null, source: channels[y1]},
  78. [y2]: {value: Y, scale: null, source: channels[y2]}
  79. }
  80. };
  81. });
  82. }
  83. function waffleRender({render, ...options}) {
  84. return {
  85. ...options,
  86. render: composeRender(render, function (index, scales, values, dimensions, context) {
  87. const {gap, rx, ry} = this;
  88. const {channels, ariaLabel, href, title, ...visualValues} = values;
  89. const {document} = context;
  90. const polygon = channels.polygon.value;
  91. const [cx, x0] = channels.cx.value;
  92. const [cy, y0] = channels.cy.value;
  93. // Create a base pattern with shared attributes for cloning.
  94. const patternId = getPatternId();
  95. const basePattern = document.createElementNS(namespaces.svg, "pattern");
  96. basePattern.setAttribute("width", cx);
  97. basePattern.setAttribute("height", cy);
  98. basePattern.setAttribute("patternUnits", "userSpaceOnUse");
  99. const basePatternRect = basePattern.appendChild(document.createElementNS(namespaces.svg, "rect"));
  100. basePatternRect.setAttribute("x", gap / 2);
  101. basePatternRect.setAttribute("y", gap / 2);
  102. basePatternRect.setAttribute("width", cx - gap);
  103. basePatternRect.setAttribute("height", cy - gap);
  104. if (rx != null) basePatternRect.setAttribute("rx", rx);
  105. if (ry != null) basePatternRect.setAttribute("ry", ry);
  106. return create("svg:g", context)
  107. .call(applyIndirectStyles, this, dimensions, context)
  108. .call(this._transform, this, scales)
  109. .call((g) =>
  110. g
  111. .selectAll()
  112. .data(index)
  113. .enter()
  114. .append(() => basePattern.cloneNode(true))
  115. .attr("id", (i) => `${patternId}-${i}`)
  116. .select("rect")
  117. .call(applyDirectStyles, this)
  118. .call(applyChannelStyles, this, visualValues)
  119. )
  120. .call((g) =>
  121. g
  122. .selectAll()
  123. .data(index)
  124. .enter()
  125. .append("path")
  126. .attr("transform", template`translate(${x0},${y0})`)
  127. .attr("d", (i) => `M${polygon[i].join("L")}Z`)
  128. .attr("fill", (i) => `url(#${patternId}-${i})`)
  129. .attr("stroke", this.stroke == null ? null : "none")
  130. .call(applyChannelStyles, this, {ariaLabel, href, title})
  131. )
  132. .node();
  133. })
  134. };
  135. }
  136. // A waffle is approximately a rectangular shape, but may have one or two corner
  137. // cuts if the starting or ending value is not an even multiple of the number of
  138. // columns (the width of the waffle in cells). We can represent any waffle by
  139. // 8 points; below is a waffle of five columns representing the interval 2–11:
  140. //
  141. // 1-0
  142. // |•7-------6
  143. // |• • • • •|
  144. // 2---3• • •|
  145. // 4-----5
  146. //
  147. // Note that points 0 and 1 always have the same y-value, points 1 and 2 have
  148. // the same x-value, and so on, so we don’t need to materialize the x- and y-
  149. // values of all points. Also note that we can’t use the already-projected y-
  150. // values because these assume that y-values are distributed linearly along y
  151. // rather than wrapping around in columns.
  152. //
  153. // The corner points may be coincident. If the ending value is an even multiple
  154. // of the number of columns, say representing the interval 2–10, then points 6,
  155. // 7, and 0 are the same.
  156. //
  157. // 1-----0/7/6
  158. // |• • • • •|
  159. // 2---3• • •|
  160. // 4-----5
  161. //
  162. // Likewise if the starting value is an even multiple, say representing the
  163. // interval 0–10, points 2–4 are coincident.
  164. //
  165. // 1-----0/7/6
  166. // |• • • • •|
  167. // |• • • • •|
  168. // 4/3/2-----5
  169. //
  170. // Waffles can also represent fractional intervals (e.g., 2.4–10.1). These
  171. // require additional corner cuts, so the implementation below generates a few
  172. // more points.
  173. //
  174. // The last point describes the centroid (used for pointing)
  175. function wafflePoints(i1, i2, columns) {
  176. if (i2 < i1) return wafflePoints(i2, i1, columns); // ensure i1 <= i2
  177. if (i1 < 0) return wafflePointsOffset(i1, i2, columns, Math.ceil(-Math.min(i1, i2) / columns)); // ensure i1 >= 0
  178. const x1f = Math.floor(i1 % columns);
  179. const x1c = Math.ceil(i1 % columns);
  180. const x2f = Math.floor(i2 % columns);
  181. const x2c = Math.ceil(i2 % columns);
  182. const y1f = Math.floor(i1 / columns);
  183. const y1c = Math.ceil(i1 / columns);
  184. const y2f = Math.floor(i2 / columns);
  185. const y2c = Math.ceil(i2 / columns);
  186. const points = [];
  187. if (y2c > y1c) points.push([0, y1c]);
  188. points.push([x1f, y1c], [x1f, y1f + (i1 % 1)], [x1c, y1f + (i1 % 1)]);
  189. if (!(i1 % columns > columns - 1)) {
  190. points.push([x1c, y1f]);
  191. if (y2f > y1f) points.push([columns, y1f]);
  192. }
  193. if (y2f > y1f) points.push([columns, y2f]);
  194. points.push([x2c, y2f], [x2c, y2f + (i2 % 1)], [x2f, y2f + (i2 % 1)]);
  195. if (!(i2 % columns < 1)) {
  196. points.push([x2f, y2c]);
  197. if (y2c > y1c) points.push([0, y2c]);
  198. }
  199. points.push(waffleCentroid(i1, i2, columns));
  200. return points;
  201. }
  202. function wafflePointsOffset(i1, i2, columns, k) {
  203. return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map(([x, y]) => [x, y - k]);
  204. }
  205. function waffleCentroid(i1, i2, columns) {
  206. const r = Math.floor(i2 / columns) - Math.floor(i1 / columns);
  207. return r === 0
  208. ? // Single row
  209. waffleRowCentroid(i1, i2, columns)
  210. : r === 1
  211. ? // Two incomplete rows; use the midpoint of their overlap if any, otherwise the larger row
  212. Math.floor(i2 % columns) > Math.ceil(i1 % columns)
  213. ? [(Math.floor(i2 % columns) + Math.ceil(i1 % columns)) / 2, Math.floor(i2 / columns)]
  214. : i2 % columns > columns - (i1 % columns)
  215. ? waffleRowCentroid(i2 - (i2 % columns), i2, columns)
  216. : waffleRowCentroid(i1, columns * Math.ceil(i1 / columns), columns)
  217. : // At least one full row; take the midpoint of all the rows that include the middle
  218. [columns / 2, (Math.round(i1 / columns) + Math.round(i2 / columns)) / 2];
  219. }
  220. function waffleRowCentroid(i1, i2, columns) {
  221. const c = Math.floor(i2) - Math.floor(i1);
  222. return c === 0
  223. ? // Single cell
  224. [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (((i1 + i2) / 2) % 1)]
  225. : c === 1
  226. ? // Two incomplete cells; use the overlap if large enough, otherwise use the largest
  227. (i2 % 1) - (i1 % 1) > 0.5
  228. ? [Math.ceil(i1 % columns), Math.floor(i2 / columns) + ((i1 % 1) + (i2 % 1)) / 2]
  229. : i2 % 1 > 1 - (i1 % 1)
  230. ? [Math.floor(i2 % columns) + 0.5, Math.floor(i2 / columns) + (i2 % 1) / 2]
  231. : [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (1 + (i1 % 1)) / 2]
  232. : // At least one full cell; take the midpoint
  233. [
  234. Math.ceil(i1 % columns) + Math.ceil(Math.floor(i2) - Math.ceil(i1)) / 2,
  235. Math.floor(i1 / columns) + (i2 >= 1 + i1 ? 0.5 : ((i1 + i2) / 2) % 1)
  236. ];
  237. }
  238. function maybeRound(round) {
  239. if (round === undefined || round === false) return Number;
  240. if (round === true) return Math.round;
  241. if (typeof round !== "function") throw new Error(`invalid round: ${round}`);
  242. return round;
  243. }
  244. function maybeMultiple(multiple) {
  245. return multiple === undefined ? undefined : Math.max(1, Math.floor(multiple));
  246. }
  247. function scaleof({domain, range}) {
  248. return spread(range) / spread(domain);
  249. }
  250. function spread(domain) {
  251. const [min, max] = extent(domain);
  252. return max - min;
  253. }
  254. export function waffleX(data, {tip, ...options} = {}) {
  255. if (!hasXY(options)) options = {...options, y: indexOf, x2: identity};
  256. return new WaffleX(data, {tip: waffleTip(tip), ...maybeStackX(maybeIntervalX(maybeIdentityX(options)))});
  257. }
  258. export function waffleY(data, {tip, ...options} = {}) {
  259. if (!hasXY(options)) options = {...options, x: indexOf, y2: identity};
  260. return new WaffleY(data, {tip: waffleTip(tip), ...maybeStackY(maybeIntervalY(maybeIdentityY(options)))});
  261. }
  262. /**
  263. * Waffle tips behave a bit unpredictably because we they are driven by the
  264. * waffle centroid; you could be hovering over a waffle segment, but more than
  265. * 40px away from its centroid, or closer to the centroid of another segment.
  266. * We’d rather show a tip, even if it’s the “wrong” one, so we increase the
  267. * default maxRadius to Infinity. The “right” way to fix this would be to use
  268. * signed distance to the waffle geometry rather than the centroid.
  269. */
  270. function waffleTip(tip) {
  271. return tip === true
  272. ? {maxRadius: Infinity}
  273. : isObject(tip) && tip.maxRadius === undefined
  274. ? {...tip, maxRadius: Infinity}
  275. : undefined;
  276. }