Skip to content

Commit 2745990

Browse files
committed
a first shot at proportional symbols
(#41)
1 parent bd5b388 commit 2745990

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-0
lines changed

src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export {Line, line, lineX, lineY} from "./marks/line.js";
1111
export {Link, link} from "./marks/link.js";
1212
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
1313
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
14+
export {Symbolic, symbol, symbolX, symbolY} from "./marks/symbol.js";
1415
export {Text, text, textX, textY} from "./marks/text.js";
1516
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
1617
export {bin1, bin2} from "./transforms/bin.js";

src/marks/symbol.js

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {ascending} from "d3-array";
2+
import {create} from "d3-selection";
3+
import {filter, nonempty, positive} from "../defined.js";
4+
import {Mark, indexOf, identity, first, second, maybeColor, maybeNumber} from "../mark.js";
5+
import {Style, applyDirectStyles, applyIndirectStyles, applyBandTransform} from "../style.js";
6+
7+
export class Symbolic extends Mark {
8+
constructor(
9+
data,
10+
{
11+
x = first,
12+
y = second,
13+
z,
14+
size,
15+
title,
16+
fill,
17+
stroke,
18+
symbol,
19+
transform,
20+
...style
21+
} = {}
22+
) {
23+
const [vsize, csize = vsize == null ? 27 : undefined] = maybeNumber(size);
24+
const [vfill, cfill = vfill == null ? "none" : undefined] = maybeColor(fill);
25+
const [vstroke, cstroke = vstroke == null && cfill === "none" ? "currentColor" : undefined] = maybeColor(stroke);
26+
super(
27+
data,
28+
[
29+
{name: "x", value: x, scale: "x"},
30+
{name: "y", value: y, scale: "y"},
31+
{name: "z", value: z, optional: true},
32+
{name: "size", value: vsize, scale: "size", optional: true},
33+
{name: "title", value: title, optional: true},
34+
{name: "fill", value: vfill, scale: "color", optional: true},
35+
{name: "stroke", value: vstroke, scale: "color", optional: true},
36+
{name: "symbol", value: symbol, optional: true}
37+
],
38+
transform
39+
);
40+
this.size = csize;
41+
Style(this, {
42+
fill: cfill,
43+
stroke: cstroke,
44+
strokeWidth: cstroke != null || vstroke != null ? 1.5 : undefined,
45+
...style
46+
});
47+
}
48+
render(
49+
I,
50+
{x, y, size, color},
51+
{x: X, y: Y, z: Z, size: A, title: L, fill: F, stroke: S, symbol: K}
52+
) {
53+
let index = filter(I, X, Y, F, S);
54+
if (A) index = index.filter(i => positive(A[i]));
55+
if (Z) index.sort((i, j) => ascending(Z[i], Z[j]));
56+
const table = new symbolTable();
57+
return create("svg:g")
58+
.call(applyIndirectStyles, this)
59+
.call(applyBandTransform, x, y)
60+
.call(g => g.selectAll()
61+
.data(index)
62+
.join("use")
63+
.call(applyDirectStyles, this)
64+
.attr("transform", i => `translate(${x(X[i])},${y(Y[i])})scale(${scale(A ? size(A[i]) : this.size)})`)
65+
.attr("fill", F && (i => color(F[i])))
66+
.attr("stroke", S && (i => color(S[i])))
67+
.attr("href", K ? (i => table.get(K[i])) : table.get("rect"))
68+
.call(L ? text => text
69+
.filter(i => nonempty(L[i]))
70+
.append("title")
71+
.text(i => L[i]) : () => {})
72+
)
73+
.call(g => g.append("defs")
74+
.selectAll()
75+
.data(table.symbols())
76+
.join("g")
77+
.attr("id", ({id}) => id)
78+
.html(({src}) => src)
79+
.selectAll("*")
80+
.attr("vector-effect", "non-scaling-stroke")
81+
)
82+
.node();
83+
}
84+
}
85+
86+
class symbolTable {
87+
constructor() {
88+
this.s = new Map();
89+
}
90+
symbols() {
91+
return this.s.values();
92+
}
93+
add(name, src) {
94+
this.s.set(name, {src, id: "toto" + name});
95+
}
96+
get(name) {
97+
if (!this.s.has(name)) {
98+
switch (name) {
99+
case "rect":
100+
this.add(name, "<rect width=2 height=2 x=-1 y=-1>");
101+
break;
102+
case "dot":
103+
default:
104+
this.add(name, "<circle r=1>");
105+
break;
106+
}
107+
}
108+
return `#${this.s.get(name).id}`;
109+
}
110+
}
111+
112+
function scale(area) {
113+
return Math.sqrt(area / 3);
114+
}
115+
116+
export function symbol(data, options) {
117+
return new Symbolic(data, options);
118+
}
119+
120+
export function symbolX(data, {x = identity, ...options} = {}) {
121+
return new Symbolic(data, {...options, x, y: indexOf});
122+
}
123+
124+
export function symbolY(data, {y = identity, ...options} = {}) {
125+
return new Symbolic(data, {...options, x: indexOf, y});
126+
}
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<body>
4+
<script src="https://cdn.jsdelivr.net/npm/d3@6"></script>
5+
<script src="../dist/@observablehq/plot.umd.js"></script>
6+
<script>
7+
8+
d3.json("data/movies.json").then(movies => {
9+
const Genre = d => d["Major Genre"] || "Other";
10+
const Profit = d => (d["Worldwide Gross"] - d["Production Budget"]) / 1e6;
11+
const genres = d3.group(movies, Genre);
12+
document.body.appendChild(Plot.plot({
13+
marginLeft: 120,
14+
x: {
15+
grid: true,
16+
inset: 6,
17+
label: "Profit ($M) →",
18+
domain: [d3.min(movies, Profit), 1e3]
19+
},
20+
y: {
21+
domain: d3.rollups(movies, movies => d3.median(movies, Profit), Genre)
22+
.sort(([, a], [, b]) => d3.descending(a, b))
23+
.map(([key]) => key)
24+
},
25+
marks: [
26+
/*
27+
Plot.ruleX([0]),
28+
Plot.barX(genres, {
29+
y: ([genre]) => genre,
30+
x1: ([, movies]) => d3.quantile(movies, 0.25, Profit),
31+
x2: ([, movies]) => d3.quantile(movies, 0.75, Profit),
32+
fillOpacity: 0.2
33+
}),
34+
*/
35+
Plot.symbol(movies, {
36+
y: Genre,
37+
x: Profit,
38+
fill: () => Math.random()*2 | 0,
39+
symbol: () => ["dot", "rect"][Math.random()*2|0],
40+
size: Math.random
41+
}),
42+
/*
43+
Plot.tickX(genres, {
44+
y: ([genre]) => genre,
45+
x: ([, movies]) => d3.median(movies, Profit),
46+
stroke: "red",
47+
strokeWidth: 2
48+
})
49+
*/
50+
]
51+
}));
52+
});
53+
54+
</script>

0 commit comments

Comments
 (0)