-
Notifications
You must be signed in to change notification settings - Fork 3
/
dotchart.coffee
269 lines (234 loc) · 11 KB
/
dotchart.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# dotchart: scatter plot where one dimension is categorical (sometimes called a strip chart)
d3panels.dotchart = (chartOpts) ->
chartOpts = {} unless chartOpts? # make sure it's defined
# chartOpts start
xcategories = chartOpts?.xcategories ? null # group categories
xcatlabels = chartOpts?.xcatlabels ? null # labels for group categories
xNA = chartOpts?.xNA ? {handle:true, force:false} # handle: include separate boxes for NAs; force: include whether or not NAs in data
yNA = chartOpts?.yNA ? {handle:true, force:false} # handle: include separate boxes for NAs; force: include whether or not NAs in data
xNA_size = chartOpts?.xNA_size ? {width:20, gap:10} # width and gap for x=NA box
yNA_size = chartOpts?.yNA_size ? {width:20, gap:10} # width and gap for y=NA box
ylim = chartOpts?.ylim ? null # y-axis limits
xlab = chartOpts?.xlab ? "Group" # x-axis title
ylab = chartOpts?.ylab ? "Response" # y-axis title
xlineOpts = chartOpts?.xlineOpts ? {color:"#cdcdcd", width:5} # color and width of vertical lines
pointcolor = chartOpts?.pointcolor ? null # fill color of points
pointstroke = chartOpts?.pointstroke ? "black" # color of points' outer circle
pointsize = chartOpts?.pointsize ? 3 # color of points
jitter = chartOpts?.jitter ? "beeswarm" # method for jittering points (beeswarm|random|none)
tipclass = chartOpts?.tipclass ? "tooltip" # class name for tool tips
horizontal = chartOpts?.horizontal ? false # whether to interchange x and y-axes
v_over_h = chartOpts?.v_over_h ? horizontal # whether vertical lines should be on top of horizontal lines
# chartOpts end
# further chartOpts: panelframe
# accessors start
xscale = null # x-axis scale
yscale = null # y-axis scale
xNA = xNA # true if x-axis NAs are handled in a separate box
yNA = yNA # true if y-axis NAs are handled in a separate box
points = null # point selection
indtip = null # tooltip selection
svg = null # SVG selection
# accessors end
## the main function
chart = (selection, data) -> # data = {x, y, indID, group} # x should be a set of positive integers; xcategories has the possible values
d3panels.displayError("dotchart: data.x is missing") unless data.x?
d3panels.displayError("dotchart: data.y is missing") unless data.y?
x = d3panels.missing2null(data.x)
y = d3panels.missing2null(data.y)
# grab indID if it's there
# if no indID, create a vector of them
indID = data?.indID ? [1..x.length]
# a few checks
if x.length != y.length
d3panels.displayError("dotchart: length(x) [#{x.length}] != length(y) [#{y.length}]")
if indID.length != x.length
d3panels.displayError("dotchart: length(indID) [#{indID.length}] != length(x) [#{x.length}]")
# groups of colors
group = data?.group ? (1 for i in x)
ngroup = d3.max(group)
group = ((if g? then g-1 else g) for g in group) # changed from (1,2,3,...) to (0,1,2,...)
if d3panels.sumArray(g < 0 or g > ngroup-1 for g in group) > 0
d3panels.displayError("dotchart: group values out of range")
console.log("ngroup: #{ngroup}")
console.log("distinct groups: #{d3panels.unique(group)}")
if group.length != x.length
d3panels.displayError("dotchart: group.length (#{group.length}) != x.length (#{x.length})")
# colors of the points in the different groups
pointcolor = pointcolor ? d3panels.selectGroupColors(ngroup, "dark")
pointcolor = d3panels.expand2vector(pointcolor, ngroup)
if pointcolor.length < ngroup
d3panels.displayError("add_points: pointcolor.length (#{pointcolor.length}) < ngroup (#{ngroup})")
xcategories = xcategories ? d3panels.unique(x)
xcatlabels = xcatlabels ? xcategories
if xcatlabels.length != xcategories.length
d3panels.displayError("dotchart: xcatlabels.length [#{xcatlabels.length}] != xcategories.length [#{xcategories.length}]")
# check all x in xcategories
if d3panels.sumArray(xv? and !(xv in xcategories) for xv in x) > 0
d3panels.displayError("dotchart: Some x values not in xcategories")
console.log("xcategories:")
console.log(xcategories)
console.log("x:")
console.log(x)
for i of x
x[i] = null if x[i]? and !(x[i] in xcategories)
# x- and y-axis limits
ylim = ylim ? d3panels.pad_ylim(d3.extent(y))
xlim = [d3.min(xcategories)-0.5, d3.max(xcategories) + 0.5]
# whether to include separate boxes for NAs
xNA.handle = xNA.force or (xNA.handle and !(x.every (v) -> (v?)))
yNA.handle = yNA.force or (yNA.handle and !(y.every (v) -> (v?)))
if horizontal
chartOpts.ylim = xlim.reverse()
chartOpts.xlim = ylim
chartOpts.xlab = ylab
chartOpts.ylab = xlab
chartOpts.xlineOpts = chartOpts.ylineOpts
chartOpts.ylineOpts = xlineOpts
chartOpts.yNA = xNA.handle
chartOpts.xNA = yNA.handle
chartOpts.xNA_size = yNA_size
chartOpts.yNA_size = xNA_size
chartOpts.yticks = xcategories
chartOpts.yticklab = xcatlabels
chartOpts.v_over_h = v_over_h
else
chartOpts.ylim = ylim
chartOpts.xlim = xlim
chartOpts.xlab = xlab
chartOpts.ylab = ylab
chartOpts.ylineOpts = chartOpts.ylineOpts
chartOpts.xlineOpts = xlineOpts
chartOpts.xNA = xNA.handle
chartOpts.yNA = yNA.handle
chartOpts.xNA_size = xNA_size
chartOpts.yNA_size = yNA_size
chartOpts.xticks = xcategories
chartOpts.xticklab = xcatlabels
chartOpts.v_over_h = v_over_h
# set up frame
myframe = d3panels.panelframe(chartOpts)
# Create SVG
myframe(selection)
svg = myframe.svg()
# grab scale functions
xscale = myframe.xscale()
yscale = myframe.yscale()
indtip = d3.tip()
.attr('class', "d3-tip #{tipclass}")
.html((d,i) -> indID[i])
.direction(() ->
return 'n' if horizontal
'e')
.offset(() ->
return [-10-pointsize,0] if horizontal
[0,10+pointsize])
svg.call(indtip)
# scaled versions of points
if horizontal
scaledPoints = ({x:xscale(y[i]),y:yscale(x[i])} for i of x)
else
scaledPoints = ({x:xscale(x[i]),y:yscale(y[i])} for i of x)
pointGroup = svg.append("g").attr("id", "points")
points =
pointGroup.selectAll("empty")
.data(scaledPoints)
.enter()
.append("circle")
.attr("class", (d,i) -> "pt#{i}")
.attr("r", pointsize)
.attr("fill", (d,i) -> pointcolor[group[i]])
.attr("stroke", pointstroke)
.attr("stroke-width", "1")
.attr("cx", (d) -> d.x)
.attr("cy", (d) -> d.y)
.on("mouseover.paneltip", indtip.show)
.on("mouseout.paneltip", indtip.hide)
if jitter == "random"
jitter_width = 0.2
u = ((Math.random()-0.5)*jitter_width for i of scaledPoints)
if horizontal
points.attr("cy", (d,i) ->
return yscale(x[i] + u[i]) if x[i]?
yscale(x[i]) + u[i]/jitter_width*xNA_size.width/2)
else
points.attr("cx", (d,i) ->
return xscale(x[i] + u[i]) if x[i]?
xscale(x[i]) + u[i]/jitter_width*xNA_size.width/2)
else if jitter == "beeswarm"
for p in scaledPoints
p.true_x = p.x
p.true_y = p.y
# nearby points
nearbyPoints = []
for i of scaledPoints
p = scaledPoints[i]
p.index = i
nearbyPoints[i] = []
for j of scaledPoints
if j != i
q = scaledPoints[j]
if horizontal
nearbyPoints[i].push(j) if p.y == q.y and Math.abs(p.x-q.x)<pointsize*2
else
nearbyPoints[i].push(j) if p.x == q.x and Math.abs(p.y-q.y)<pointsize*2
gravity = (p, alpha) ->
if horizontal
p.y -= (p.y - p.true_y)*alpha
else
p.x -= (p.x - p.true_x)*alpha
collision = (p, alpha) ->
for i in nearbyPoints[p.index]
q = scaledPoints[i]
dx = p.x - q.x
dy = p.y - q.y
d = Math.sqrt(dx*dx + dy*dy)
if d < pointsize*2
if horizontal
if dy < 0
p.y -= (pointsize*2 - d)*alpha
q.y += (pointsize*2 - d)*alpha
else
p.y += (pointsize*2 - d)*alpha
q.y -= (pointsize*2 - d)*alpha
else
if dx < 0
p.x -= (pointsize*2 - d)*alpha
q.x += (pointsize*2 - d)*alpha
else
p.x += (pointsize*2 - d)*alpha
q.x -= (pointsize*2 - d)*alpha
tick = (e) ->
for p in scaledPoints
collision(p, e.alpha*5)
for p in scaledPoints
gravity(p, e.alpha/5)
if horizontal
points.attr("cy", (d) -> d.y)
else
points.attr("cx", (d) -> d.x)
force = d3.layout.force()
.gravity(0)
.charge(0)
.nodes(scaledPoints)
.on("tick", tick)
.start()
else if jitter != "none"
d3panels.displayError('dotchart: jitter should be "beeswarm", "random", or "none"')
# move box to front
myframe.box().moveToFront()
# functions to grab stuff
chart.xscale = () -> xscale
chart.yscale = () -> yscale
chart.xNA = () -> xNA.handle
chart.yNA = () -> yNA.handle
chart.points = () -> points
chart.indtip = () -> indtip
chart.svg = () -> svg
# function to remove chart
chart.remove = () ->
svg.remove()
indtip.destroy()
return null
# return the chart function
chart