diff --git a/src/index.js b/src/index.js index 963d508869..5aa81ba780 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ export {plot} from "./plot.js"; export {Mark} from "./mark.js"; +export {Arc, arc} from "./marks/arc.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {AxisX, AxisY} from "./marks/axis.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; @@ -9,6 +10,7 @@ export {Dot, dot, dotX, dotY} from "./marks/dot.js"; export {group, groupX, groupY} from "./marks/group.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {Link, link} from "./marks/link.js"; +export {pie} from "./marks/pie.js"; export {Rect, rect, rectX, rectY} from "./marks/rect.js"; export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; diff --git a/src/marks/arc.js b/src/marks/arc.js new file mode 100644 index 0000000000..4993f6828a --- /dev/null +++ b/src/marks/arc.js @@ -0,0 +1,121 @@ +import {ascending} from "d3-array"; +import {create} from "d3-selection"; +import {arc as shapeArc} from "d3-shape"; +import {filter, nonempty} from "../defined.js"; +import {Mark, maybeColor, maybeNumber, title} from "../mark.js"; +import {Style, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; + +const constant = x => () => x; + +export class Arc extends Mark { + constructor( + data, + { + startAngle = d => d.startAngle, + endAngle = d => d.endAngle, + x = constant(0), + y = constant(0), + innerRadius, + outerRadius, + padAngle, + z, + title, + label, + fill, + stroke, + transform, + ...style + } = {} + ) { + const [vfill, cfill] = maybeColor(fill, "currentColor"); + const [vstroke, cstroke] = maybeColor(stroke, cfill === "none" ? "currentColor" : cfill === "currentColor" ? "white" : "none"); + const [vsa, csa] = maybeNumber(startAngle, 0); + const [vea, cea] = maybeNumber(endAngle, 2 * Math.PI); + const [vpa, cpa] = maybeNumber(padAngle, 0); + const [vx, cx] = maybeNumber(x, 0); + const [vy, cy] = maybeNumber(y, 0); + const [vri, cri] = maybeNumber(innerRadius, 0); + const [vro, cro] = maybeNumber(outerRadius, 100); + + super( + data, + [ + {name: "x", value: vx, scale: "x", optional: true}, + {name: "y", value: vy, scale: "y", optional: true}, + {name: "startAngle", value: vsa, optional: true}, + {name: "endAngle", value: vea, optional: true}, + {name: "innerRadius", value: vri, optional: true}, + {name: "outerRadius", value: vro, optional: true}, + {name: "padAngle", value: vpa, optional: true}, + {name: "z", value: z, optional: true}, + {name: "title", value: title, optional: true}, + {name: "label", value: label, optional: true}, + {name: "fill", value: vfill, scale: "color", optional: true}, + {name: "stroke", value: vstroke, scale: "color", optional: true} + ], + transform + ); + Style(this, {fill: cfill, stroke: cstroke, ...style}); + this.x = cx; + this.y = cy; + this.sa = csa; + this.ea = cea; + this.ri = cri; + this.ro = cro; + this.pa = cpa; + } + render( + I, + {x, y, color}, + {startAngle: SA, endAngle: EA, innerRadius: RI, outerRadius: RO, padAngle: PA, x: X, y: Y, z: Z, title: T, label: L, fill: F, stroke: S}, + {marginTop, marginRight, marginBottom, marginLeft, width, height} + ) { + const index = filter(I, SA, EA, F, S); + if (Z) index.sort((i, j) => ascending(Z[i], Z[j])); + + const r0 = Math.min(width - marginLeft - marginRight, height - marginTop - marginBottom) / 200; + + const arc = shapeArc() + .startAngle(SA ? (i => SA[i]) : this.sa) + .endAngle(EA ? (i => EA[i]) : this.ea) + .innerRadius(RI ? (i => r0 * RI[i]) : r0 * this.ri) + .outerRadius(RO ? (i => r0 * RO[i]) : r0 * this.ro) + .padAngle(PA ? (i => PA[i]) : this.pa); + + const wrapper = create("svg:g") + .call(applyTransform, x, y); + wrapper + .append("g") + .call(applyIndirectStyles, this) + .call(g => g.selectAll() + .data(index) + .join("path") + .call(applyDirectStyles, this) + .attr("d", arc) + .attr("transform", i => `translate(${x(X[i])},${y(Y[i])})`) + .attr("fill", F && (i => color(F[i]))) + .attr("stroke", S && (i => color(S[i]))) + .call(title(T))); + if (L) wrapper.append("g").call(_label(L, index, arc)); + return wrapper.node(); + + function _label(L, index, arc) { + return L ? g => { + g.append("g") + .selectAll("text") + .data(index.filter(i => nonempty(L[i]))) + .join("g") + .attr("transform", i => `translate(${x(X[i])},${y(Y[i])})`) + .append("text") + .text(i => L[i]) + .attr("transform", i => `translate(${arc.centroid(i)})`) + .attr("text-anchor", "center") + .style("fill", "black"); + } : () => {}; + } + } +} + +export function arc(data, options) { + return new Arc(data, options); +} diff --git a/src/marks/pie.js b/src/marks/pie.js new file mode 100644 index 0000000000..f7e911377c --- /dev/null +++ b/src/marks/pie.js @@ -0,0 +1,33 @@ +import {arc} from "./arc.js"; +import {pie as transformPie} from "../transforms/pie.js"; +import {field} from "../mark.js"; + +export function pie(data, { + value, + sort, + sortValues, + startAngle, + transform, + endAngle, + padAngle, + ...options +} = {}) { + const pie = transformPie(); + if (value !== undefined) pie.value(typeof value === "string" ? field(value) : value); + if (sort !== undefined) pie.sort(sort || noSort); + if (sortValues !== undefined) pie.sortValues(sortValues || noSort); + if (startAngle !== undefined) pie.startAngle(startAngle); + if (endAngle !== undefined) pie.endAngle(endAngle); + if (padAngle !== undefined) pie.padAngle(padAngle); + + return arc( + data, + { + ...options, + padAngle, + transform: (!transform ? pie : data => pie(transform(data))) + } + ); +} + +function noSort() {} diff --git a/src/transforms/pie.js b/src/transforms/pie.js new file mode 100644 index 0000000000..6dfd744990 --- /dev/null +++ b/src/transforms/pie.js @@ -0,0 +1,3 @@ +import {pie as shapePie} from "d3-shape"; + +export const pie = shapePie; \ No newline at end of file diff --git a/test/marks/arc-test.js b/test/marks/arc-test.js new file mode 100644 index 0000000000..0f9c9bd764 --- /dev/null +++ b/test/marks/arc-test.js @@ -0,0 +1,151 @@ +import * as Plot from "@observablehq/plot"; +import tape from "tape-await"; + +tape("arc(data) has the expected defaults", test => { + const arc = Plot.arc(undefined); + test.strictEqual(arc.data, undefined); + test.strictEqual(arc.transform("foo"), "foo"); + test.deepEqual(arc.channels.map(c => c.name), ["x", "y", "startAngle", "endAngle"]); + test.deepEqual(arc.channels.map(c => c.scale), ["x", "y", undefined, undefined]); + test.strictEqual(arc.fill, undefined); + test.strictEqual(arc.fillOpacity, undefined); + test.strictEqual(arc.stroke, "white"); + test.strictEqual(arc.strokeWidth, undefined); + test.strictEqual(arc.strokeOpacity, undefined); + test.strictEqual(arc.strokeLinejoin, undefined); + test.strictEqual(arc.strokeLinecap, undefined); + test.strictEqual(arc.strokeMiterlimit, undefined); + test.strictEqual(arc.strokeDasharray, undefined); + test.strictEqual(arc.mixBlendMode, undefined); +}); + +tape("arc(data) specifies an optional x channel", test => { + const arc = Plot.arc(undefined); + const x = arc.channels.find(c => c.name === "x"); + test.strictEqual(x.value({}), 0); + test.strictEqual(x.scale, "x"); +}); + +tape("arc(data) specifies an optional y channel", test => { + const arc = Plot.arc(undefined); + const y = arc.channels.find(c => c.name === "y"); + test.strictEqual(y.value({}), 0); + test.strictEqual(y.scale, "y"); +}); + +tape("arc(data) specifies an optional startAngle channel", test => { + const arc = Plot.arc(undefined); + const startAngle = arc.channels.find(c => c.name === "startAngle"); + test.strictEqual(startAngle.value({startAngle: 12}), 12); + test.strictEqual(startAngle.scale, undefined); +}); + +tape("arc(data) specifies an optional endAngle channel", test => { + const arc = Plot.arc(undefined); + const endAngle = arc.channels.find(c => c.name === "endAngle"); + test.strictEqual(endAngle.value({endAngle: 42}), 42); + test.strictEqual(endAngle.scale, undefined); +}); + +tape("arc(data, {z}) specifies an optional z channel", test => { + const arc = Plot.arc(undefined, {z: "x"}); + const z = arc.channels.find(c => c.name === "z"); + test.strictEqual(z.value.label, "x"); + test.strictEqual(z.scale, undefined); +}); + +tape("arc(data, {startAngle}) allows startAngle to be a constant", test => { + const arc = Plot.arc(undefined, {startAngle: 42}); + test.strictEqual(arc.sa, 42); +}); + +tape("arc(data, {startAngle}) allows startAngle to be a variable", test => { + const arc = Plot.arc(undefined, {startAngle: "x"}); + test.strictEqual(arc.sa, undefined); + const r = arc.channels.find(c => c.name === "startAngle"); + test.strictEqual(r.value.label, "x"); + test.strictEqual(r.scale, undefined); +}); + +tape("arc(data, {endAngle}) allows endAngle to be a constant", test => { + const arc = Plot.arc(undefined, {endAngle: 42}); + test.strictEqual(arc.ea, 42); +}); + +tape("arc(data, {endAngle}) allows endAngle to be a variable", test => { + const arc = Plot.arc(undefined, {endAngle: "x"}); + test.strictEqual(arc.ea, undefined); + const r = arc.channels.find(c => c.name === "endAngle"); + test.strictEqual(r.value.label, "x"); + test.strictEqual(r.scale, undefined); +}); + +tape("arc(data, {innerRadius}) allows innerRadius to be a constant", test => { + const arc = Plot.arc(undefined, {innerRadius: 42}); + test.strictEqual(arc.ri, 42); +}); + +tape("arc(data, {innerRadius}) allows innerRadius to be a variable", test => { + const arc = Plot.arc(undefined, {innerRadius: "x"}); + test.strictEqual(arc.ri, undefined); + const r = arc.channels.find(c => c.name === "innerRadius"); + test.strictEqual(r.value.label, "x"); + test.strictEqual(r.scale, undefined); +}); + +tape("arc(data, {outerRadius}) allows outerRadius to be a constant", test => { + const arc = Plot.arc(undefined, {outerRadius: 42}); + test.strictEqual(arc.ro, 42); +}); + +tape("arc(data, {outerRadius}) allows outerRadius to be a variable", test => { + const arc = Plot.arc(undefined, {outerRadius: "x"}); + test.strictEqual(arc.ro, undefined); + const r = arc.channels.find(c => c.name === "outerRadius"); + test.strictEqual(r.value.label, "x"); + test.strictEqual(r.scale, undefined); +}); + +tape("arc(data, {title}) specifies an optional title channel", test => { + const arc = Plot.arc(undefined, {x1: "0", y1: "1", title: "2"}); + const title = arc.channels.find(c => c.name === "title"); + test.strictEqual(title.value.label, "2"); + test.strictEqual(title.scale, undefined); +}); + +tape("arc(data, {fill}) allows fill to be a constant color", test => { + const arc = Plot.arc(undefined, {x1: "0", y1: "1", fill: "red"}); + test.strictEqual(arc.fill, "red"); +}); + +tape("arc(data, {fill}) allows fill to be null", test => { + const arc = Plot.arc(undefined, {x1: "0", y1: "1", fill: null}); + test.strictEqual(arc.fill, "none"); +}); + +tape("arc(data, {fill}) allows fill to be a variable color", test => { + const arc = Plot.arc(undefined, {x1: "0", y1: "1", fill: "x"}); + test.strictEqual(arc.fill, undefined); + const fill = arc.channels.find(c => c.name === "fill"); + test.strictEqual(fill.value.label, "x"); + test.strictEqual(fill.scale, "color"); +}); + +tape("arc(data, {stroke}) allows stroke to be a constant color", test => { + const arc = Plot.arc(undefined, {x1: "0", y1: "1", stroke: "red"}); + test.strictEqual(arc.stroke, "red"); +}); + +tape("arc(data, {stroke}) allows stroke to be null", test => { + const arc = Plot.arc(undefined, {x1: "0", y1: "1", stroke: null}); + test.strictEqual(arc.stroke, undefined); +}); + +tape("arc(data, {stroke}) allows stroke to be a variable color", test => { + const arc = Plot.arc(undefined, {x1: "0", y1: "1", stroke: "x"}); + test.strictEqual(arc.stroke, undefined); + const stroke = arc.channels.find(c => c.name === "stroke"); + test.strictEqual(stroke.value.label, "x"); + test.strictEqual(stroke.scale, "color"); +}); +