ductwork 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/app/assets/javascripts/ductwork/application.js +50 -41
- data/app/assets/stylesheets/ductwork/application.css +859 -146
- data/app/controllers/ductwork/application_controller.rb +39 -8
- data/app/controllers/ductwork/dashboards_controller.rb +44 -11
- data/app/controllers/ductwork/pipelines_controller.rb +47 -7
- data/app/controllers/ductwork/step_errors_controller.rb +9 -5
- data/app/helpers/ductwork/application_helper.rb +107 -18
- data/app/views/ductwork/dashboards/show.html.erb +75 -126
- data/app/views/ductwork/pipelines/_graph.html.erb +37 -0
- data/app/views/ductwork/pipelines/_step.html.erb +76 -0
- data/app/views/ductwork/pipelines/index.html.erb +11 -101
- data/app/views/ductwork/pipelines/show.html.erb +62 -285
- data/app/views/ductwork/shared/_error.html.erb +7 -0
- data/app/views/ductwork/shared/_filter_menu.html.erb +13 -0
- data/app/views/ductwork/shared/_pager.html.erb +16 -0
- data/app/views/ductwork/shared/_runs_table.html.erb +30 -0
- data/app/views/ductwork/shared/_status_tabs.html.erb +11 -0
- data/app/views/ductwork/step_errors/index.html.erb +52 -96
- data/app/views/layouts/ductwork/application.html.erb +19 -23
- data/lib/ductwork/engine.rb +0 -2
- data/lib/ductwork/models/pipeline.rb +52 -31
- data/lib/ductwork/pipeline_graph/layout.rb +161 -0
- data/lib/ductwork/pipeline_graph.rb +231 -0
- data/lib/ductwork/version.rb +1 -1
- metadata +11 -5
- data/app/assets/images/ductwork/octagon-x.svg +0 -1
- data/app/assets/stylesheets/ductwork/vendor/flexboxgrid.min.css +0 -1
- data/app/assets/stylesheets/ductwork/vendor/pico.min.css +0 -4
|
@@ -1,101 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
<h3>Step Errors</h3>
|
|
1
|
+
<% content_for(:title, "Step errors") %>
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
<details class="dropdown filter-group-item">
|
|
9
|
-
<summary><%= params[:klass] || "Step Class" %></summary>
|
|
10
|
-
<ul>
|
|
11
|
-
<% @klasses.each do |klass| %>
|
|
12
|
-
<li>
|
|
13
|
-
<%= link_to(klass, step_errors_path(**params.permit(:status).merge(klass: klass))) %>
|
|
14
|
-
</li>
|
|
15
|
-
<% end %>
|
|
16
|
-
</ul>
|
|
17
|
-
</details>
|
|
18
|
-
|
|
19
|
-
<%= link_to("Clear", step_errors_path) %>
|
|
20
|
-
</div>
|
|
3
|
+
<div class="page-head">
|
|
4
|
+
<h1 class="page-title">Step errors</h1>
|
|
5
|
+
</div>
|
|
21
6
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
←
|
|
26
|
-
<% end %>
|
|
27
|
-
</button>
|
|
28
|
-
<button data-href="<%= next_page_path %>">
|
|
29
|
-
→
|
|
30
|
-
</button>
|
|
31
|
-
</div>
|
|
7
|
+
<div class="toolbar">
|
|
8
|
+
<div class="menus">
|
|
9
|
+
<%= render("ductwork/shared/filter_menu", label: "Class", param: :klass, options: @klasses) %>
|
|
32
10
|
</div>
|
|
33
|
-
|
|
34
|
-
<thead>
|
|
35
|
-
<tr>
|
|
36
|
-
<th>Step ID</th>
|
|
37
|
-
<th>Step Class</th>
|
|
38
|
-
<th>Pipeline ID</th>
|
|
39
|
-
<th>Error Message</th>
|
|
40
|
-
<th>Error Backtrace</th>
|
|
41
|
-
</tr>
|
|
42
|
-
</thead>
|
|
43
|
-
|
|
44
|
-
<tbody>
|
|
45
|
-
<% @step_errors.each do |result| %>
|
|
46
|
-
<tr class="not-clickable">
|
|
47
|
-
<td>
|
|
48
|
-
<code>
|
|
49
|
-
<%= result.execution.job.step.id %>
|
|
50
|
-
</code>
|
|
51
|
-
</td>
|
|
52
|
-
<td>
|
|
53
|
-
<code>
|
|
54
|
-
<%= result.execution.job.step.klass %>
|
|
55
|
-
</code>
|
|
56
|
-
</td>
|
|
57
|
-
<td>
|
|
58
|
-
<code>
|
|
59
|
-
<%= link_to(result.execution.job.step.run.pipeline_id, pipeline_path(result.execution.job.step.run.pipeline_id)) %>
|
|
60
|
-
</code>
|
|
61
|
-
</td>
|
|
62
|
-
<td>
|
|
63
|
-
<code>
|
|
64
|
-
<%= [result.error_klass, result.error_message].join(": ") %>
|
|
65
|
-
</code>
|
|
66
|
-
</td>
|
|
67
|
-
<td>
|
|
68
|
-
<button data-open-modal="modal-<%= result.id %>" class="tight-timestamp secondary">
|
|
69
|
-
Open
|
|
70
|
-
</button>
|
|
71
|
-
|
|
72
|
-
<dialog id="modal-<%= result.id %>">
|
|
73
|
-
<article>
|
|
74
|
-
<header>
|
|
75
|
-
<button aria-label="Close" rel="prev" data-close-modal="modal-<%= result.id %>"></button>
|
|
76
|
-
<p>
|
|
77
|
-
<strong>Error Backtrace</strong>
|
|
78
|
-
</p>
|
|
79
|
-
</header>
|
|
80
|
-
<div>
|
|
81
|
-
<h2>
|
|
82
|
-
<%= result.error_klass %>
|
|
83
|
-
</h2>
|
|
84
|
-
<h5>
|
|
85
|
-
<%= result.error_message %>
|
|
86
|
-
</h5>
|
|
87
|
-
<code>
|
|
88
|
-
<%= (result.error_backtrace&.split("\n") || []).each do |line| %>
|
|
89
|
-
<div><%= line %></div>
|
|
90
|
-
<% end %>
|
|
91
|
-
</code>
|
|
92
|
-
</div>
|
|
93
|
-
</article>
|
|
94
|
-
</dialog>
|
|
11
|
+
</div>
|
|
95
12
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
13
|
+
<div class="panel">
|
|
14
|
+
<% if @step_errors.empty? %>
|
|
15
|
+
<p class="empty">No step errors match.</p>
|
|
16
|
+
<% else %>
|
|
17
|
+
<div class="row-grid row-grid-errors row-head">
|
|
18
|
+
<span>When</span>
|
|
19
|
+
<span>Error</span>
|
|
20
|
+
<span>Step</span>
|
|
21
|
+
<span>Pipeline</span>
|
|
22
|
+
<span></span>
|
|
23
|
+
</div>
|
|
24
|
+
<% @step_errors.each do |result| %>
|
|
25
|
+
<% step = result.execution.job.step %>
|
|
26
|
+
<details class="row">
|
|
27
|
+
<summary class="row-grid row-grid-errors">
|
|
28
|
+
<span class="wide-only"><%= timestamp(result.created_at) %></span>
|
|
29
|
+
<span class="cell-truncate">
|
|
30
|
+
<span class="error-title"><%= result.error_klass %></span>
|
|
31
|
+
<%= result.error_message %>
|
|
32
|
+
</span>
|
|
33
|
+
<span class="cell-truncate wide-only"><%= step.klass %></span>
|
|
34
|
+
<span class="wide-only"><%= short_id(step.run.pipeline_id) %></span>
|
|
35
|
+
</summary>
|
|
36
|
+
<div class="row-body">
|
|
37
|
+
<div class="detail-grid">
|
|
38
|
+
<div>
|
|
39
|
+
<span class="detail-label">Step</span>
|
|
40
|
+
<div><%= step.klass %> <code><%= step.id %></code></div>
|
|
41
|
+
</div>
|
|
42
|
+
<div>
|
|
43
|
+
<span class="detail-label">Pipeline</span>
|
|
44
|
+
<%= link_to(step.run.pipeline_klass, pipeline_path(step.run.pipeline_id)) %>
|
|
45
|
+
<code><%= step.run.pipeline_id %></code>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="detail-block">
|
|
49
|
+
<%= render("ductwork/shared/error", result:) %>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</details>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% end %>
|
|
101
55
|
</div>
|
|
56
|
+
|
|
57
|
+
<%= render("ductwork/shared/pager", total: @error_count) %>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
2
|
+
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
<
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title><%= [content_for(:title), "Ductwork"].compact.join(" · ") %></title>
|
|
5
6
|
|
|
6
7
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7
8
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
@@ -11,8 +12,6 @@
|
|
|
11
12
|
<%= csrf_meta_tags %>
|
|
12
13
|
<%= csp_meta_tag %>
|
|
13
14
|
|
|
14
|
-
<%= stylesheet_link_tag "ductwork/vendor/pico.min.css" %>
|
|
15
|
-
<%= stylesheet_link_tag "ductwork/vendor/flexboxgrid.min.css" %>
|
|
16
15
|
<%= stylesheet_link_tag "ductwork/application.css" %>
|
|
17
16
|
|
|
18
17
|
<%= javascript_include_tag "ductwork/application.js", type: "module" %>
|
|
@@ -22,27 +21,24 @@
|
|
|
22
21
|
</head>
|
|
23
22
|
|
|
24
23
|
<body>
|
|
25
|
-
<header class="
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
<%=
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
</div>
|
|
41
|
-
</div>
|
|
42
|
-
</nav>
|
|
24
|
+
<header class="topbar">
|
|
25
|
+
<div class="shell topbar-inner">
|
|
26
|
+
<%= link_to(dashboard_path, class: "brand") do %>
|
|
27
|
+
<%= image_tag("ductwork/logo.png", alt: "") %>
|
|
28
|
+
<span>Ductwork</span>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<nav class="nav" aria-label="Dashboard">
|
|
32
|
+
<%= link_to("Overview", dashboard_path, aria: { current: controller_name == "dashboards" ? "page" : nil }) %>
|
|
33
|
+
<%= link_to("Pipelines", pipelines_path, aria: { current: controller_name == "pipelines" ? "page" : nil }) %>
|
|
34
|
+
<%= link_to("Errors", step_errors_path, aria: { current: controller_name == "step_errors" ? "page" : nil }) %>
|
|
35
|
+
</nav>
|
|
36
|
+
|
|
37
|
+
<span class="version">v<%= Ductwork::VERSION %></span>
|
|
38
|
+
</div>
|
|
43
39
|
</header>
|
|
44
40
|
|
|
45
|
-
<main class="
|
|
41
|
+
<main class="shell">
|
|
46
42
|
<%= yield %>
|
|
47
43
|
</main>
|
|
48
44
|
</body>
|
data/lib/ductwork/engine.rb
CHANGED
|
@@ -11,8 +11,6 @@ module Ductwork
|
|
|
11
11
|
initializer "ductwork.assets.precompile" do |app|
|
|
12
12
|
if app.config.respond_to?(:assets)
|
|
13
13
|
app.config.assets.precompile += %w[
|
|
14
|
-
ductwork/vendor/pico.min.css
|
|
15
|
-
ductwork/vendor/flexboxgrid.min.css
|
|
16
14
|
ductwork/application.css
|
|
17
15
|
ductwork/application.js
|
|
18
16
|
]
|
|
@@ -30,6 +30,9 @@ module Ductwork
|
|
|
30
30
|
class DefinitionError < StandardError; end
|
|
31
31
|
class ReviveError < StandardError; end
|
|
32
32
|
|
|
33
|
+
DEFAULT_STEP_ORDER_PRECISION = 6
|
|
34
|
+
private_constant :DEFAULT_STEP_ORDER_PRECISION
|
|
35
|
+
|
|
33
36
|
class << self
|
|
34
37
|
attr_reader :pipeline_definition
|
|
35
38
|
|
|
@@ -151,16 +154,7 @@ module Ductwork
|
|
|
151
154
|
|
|
152
155
|
new_branch.save!
|
|
153
156
|
|
|
154
|
-
branch.steps.where(status:)
|
|
155
|
-
new_step = step.dup
|
|
156
|
-
new_step.source_step = step
|
|
157
|
-
new_step.branch = new_branch
|
|
158
|
-
new_step.run = new_run
|
|
159
|
-
new_step.started_at = now
|
|
160
|
-
new_step.completed_at = now
|
|
161
|
-
|
|
162
|
-
new_step.save!
|
|
163
|
-
end
|
|
157
|
+
duplicate_succeeded_steps(branch.steps.where(status:), new_branch, new_run, now)
|
|
164
158
|
end
|
|
165
159
|
end
|
|
166
160
|
|
|
@@ -180,36 +174,60 @@ module Ductwork
|
|
|
180
174
|
|
|
181
175
|
def revive_branch_steps(branch, new_branch, new_run, now)
|
|
182
176
|
duplicate_prior_steps_as_completed(branch, new_branch, new_run, now)
|
|
177
|
+
revive_latest_step(branch.latest_step, new_branch, new_run, now)
|
|
178
|
+
end
|
|
183
179
|
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
def revive_latest_step(latest_step, new_branch, new_run, now)
|
|
181
|
+
if latest_step.failed?
|
|
182
|
+
re_enqueue_failed_step(latest_step, new_branch, new_run, now)
|
|
186
183
|
else
|
|
187
|
-
re_advance_latest_step(
|
|
184
|
+
re_advance_latest_step(latest_step, new_branch, new_run, now)
|
|
188
185
|
end
|
|
189
186
|
end
|
|
190
187
|
|
|
191
188
|
def duplicate_prior_steps_as_completed(branch, new_branch, new_run, now)
|
|
192
|
-
latest_step = branch.
|
|
189
|
+
latest_step = branch.latest_step
|
|
193
190
|
|
|
194
191
|
return if latest_step.blank?
|
|
195
192
|
|
|
196
193
|
status = %i[advancing waiting completed]
|
|
197
194
|
id = latest_step.id
|
|
198
195
|
|
|
199
|
-
branch.steps.where(status:).where.not(id:)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
196
|
+
steps = branch.steps.where(status:).where.not(id:)
|
|
197
|
+
|
|
198
|
+
duplicate_succeeded_steps(steps, new_branch, new_run, now, steps_after: 1)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def duplicate_succeeded_steps(steps, new_branch, new_run, now, steps_after: 0)
|
|
202
|
+
ordered_steps = steps.includes(:job).order(:started_at, :id).to_a
|
|
203
|
+
tick = step_order_tick
|
|
204
|
+
|
|
205
|
+
ordered_steps.reverse_each.with_index(steps_after) do |step, ticks_back|
|
|
206
|
+
started_at = now - (ticks_back * tick)
|
|
206
207
|
|
|
207
|
-
|
|
208
|
+
duplicate_succeeded_step(step, new_branch, new_run, now, started_at)
|
|
208
209
|
end
|
|
209
210
|
end
|
|
210
211
|
|
|
211
|
-
def
|
|
212
|
-
|
|
212
|
+
def step_order_tick
|
|
213
|
+
precision = Ductwork::Step.columns_hash["started_at"].precision
|
|
214
|
+
|
|
215
|
+
Rational(1, 10**(precision || DEFAULT_STEP_ORDER_PRECISION))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def duplicate_succeeded_step(step, new_branch, new_run, now, started_at)
|
|
219
|
+
new_step = step.dup
|
|
220
|
+
new_step.source_step = step
|
|
221
|
+
new_step.branch = new_branch
|
|
222
|
+
new_step.run = new_run
|
|
223
|
+
new_step.started_at = started_at
|
|
224
|
+
new_step.completed_at = now
|
|
225
|
+
|
|
226
|
+
new_step.save!
|
|
227
|
+
duplicate_job(step.job, new_step, now) if step.job.present?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def re_enqueue_failed_step(failed_step, new_branch, new_run, now)
|
|
213
231
|
args = JSON.parse(failed_step.job.input_args).fetch("args")
|
|
214
232
|
step = new_branch.steps.create!(
|
|
215
233
|
run: new_run,
|
|
@@ -223,8 +241,7 @@ module Ductwork
|
|
|
223
241
|
Ductwork::Job.enqueue(step, *args)
|
|
224
242
|
end
|
|
225
243
|
|
|
226
|
-
def re_advance_latest_step(
|
|
227
|
-
latest_step = branch.steps.order(started_at: :desc).first
|
|
244
|
+
def re_advance_latest_step(latest_step, new_branch, new_run, now)
|
|
228
245
|
step = new_branch.steps.create!(
|
|
229
246
|
run: new_run,
|
|
230
247
|
source_step: latest_step,
|
|
@@ -234,11 +251,15 @@ module Ductwork
|
|
|
234
251
|
status: :advancing,
|
|
235
252
|
started_at: now
|
|
236
253
|
)
|
|
237
|
-
job
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
job.
|
|
254
|
+
duplicate_job(latest_step.job, step, now)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def duplicate_job(job, new_step, now)
|
|
258
|
+
new_job = job.dup
|
|
259
|
+
new_job.step = new_step
|
|
260
|
+
new_job.started_at = now
|
|
261
|
+
new_job.completed_at = now
|
|
262
|
+
new_job.save!
|
|
242
263
|
end
|
|
243
264
|
|
|
244
265
|
def conditionally_duplicate_context(last_run, new_run, now, duplicate_context)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ductwork
|
|
4
|
+
class PipelineGraph
|
|
5
|
+
# Places a pipeline's nodes in columns by longest path from the start, so
|
|
6
|
+
# branches of different lengths still line up where they merge. A link
|
|
7
|
+
# that spans several columns is routed through a placeholder in each one
|
|
8
|
+
# so it runs around the cards in between rather than behind them.
|
|
9
|
+
class Layout
|
|
10
|
+
CARD_WIDTH = 208
|
|
11
|
+
COLUMN_GAP = 48
|
|
12
|
+
ROW_GAP = 24
|
|
13
|
+
PADDING = 8
|
|
14
|
+
SWEEPS = 8
|
|
15
|
+
|
|
16
|
+
attr_reader :width, :height
|
|
17
|
+
|
|
18
|
+
def initialize(nodes:, links:, card_height:)
|
|
19
|
+
@nodes = nodes
|
|
20
|
+
@links = links
|
|
21
|
+
@card_height = card_height
|
|
22
|
+
@pitch = card_height + ROW_GAP
|
|
23
|
+
|
|
24
|
+
assign_columns
|
|
25
|
+
subdivide_links
|
|
26
|
+
order_columns
|
|
27
|
+
assign_rows
|
|
28
|
+
measure
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def position(node)
|
|
32
|
+
[x(node), y(node)]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def route(from, to)
|
|
36
|
+
chain = chains.fetch([from, to])
|
|
37
|
+
start = "M#{right(chain.first).join(" ")}"
|
|
38
|
+
curves = chain.each_cons(2).map { |a, b| curve(a, b) }
|
|
39
|
+
|
|
40
|
+
[start, *curves].join(" ")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :nodes, :links, :card_height, :pitch, :column, :columns, :chains, :rows,
|
|
46
|
+
:predecessors, :successors
|
|
47
|
+
|
|
48
|
+
def assign_columns
|
|
49
|
+
@column = nodes.to_h { |node| [node, 0] }
|
|
50
|
+
outgoing = links.group_by(&:first)
|
|
51
|
+
|
|
52
|
+
nodes.each do |node|
|
|
53
|
+
outgoing.fetch(node, []).map(&:last).each do |to|
|
|
54
|
+
column[to] = [column[to], column[node] + 1].max
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def subdivide_links
|
|
60
|
+
@predecessors = Hash.new { |hash, key| hash[key] = [] }
|
|
61
|
+
@successors = Hash.new { |hash, key| hash[key] = [] }
|
|
62
|
+
@chains = links.to_h { |from, to| [[from, to], chain_for(from, to)] }
|
|
63
|
+
|
|
64
|
+
chains.each_value do |chain|
|
|
65
|
+
chain.each_cons(2) do |a, b|
|
|
66
|
+
successors[a] << b
|
|
67
|
+
predecessors[b] << a
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def chain_for(from, to)
|
|
73
|
+
placeholders = ((column[from] + 1)...column[to]).map do |index|
|
|
74
|
+
[from, to, index].tap { |placeholder| column[placeholder] = index }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
[from, *placeholders, to]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def order_columns
|
|
81
|
+
@columns = column.keys.group_by { |vertex| column[vertex] }.sort.map(&:last)
|
|
82
|
+
|
|
83
|
+
(1...columns.length).each do |index|
|
|
84
|
+
ranks = columns[index - 1].each_with_index.to_h
|
|
85
|
+
columns[index] = columns[index].sort_by.with_index do |vertex, position|
|
|
86
|
+
[mean(predecessors[vertex].filter_map { |predecessor| ranks[predecessor] }) || position, position]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def assign_rows
|
|
92
|
+
@rows = {}
|
|
93
|
+
columns.each do |vertices|
|
|
94
|
+
vertices.each_with_index { |vertex, index| rows[vertex] = (index - ((vertices.length - 1) / 2.0)) * pitch }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
SWEEPS.times do
|
|
98
|
+
sweep(columns, predecessors)
|
|
99
|
+
sweep(columns.reverse, successors)
|
|
100
|
+
end
|
|
101
|
+
sweep(columns, predecessors)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def sweep(ordered_columns, neighbors)
|
|
105
|
+
ordered_columns.each do |vertices|
|
|
106
|
+
targets = vertices.map { |vertex| mean(neighbors[vertex].map { |other| rows[other] }) || rows[vertex] }
|
|
107
|
+
|
|
108
|
+
vertices.zip(separate(targets)) { |vertex, row| rows[vertex] = row }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Pushes rows apart to at least `pitch`, keeping their order and their
|
|
113
|
+
# overall center where the targets asked for it.
|
|
114
|
+
def separate(targets)
|
|
115
|
+
placed = targets.each_with_object([]) do |target, result|
|
|
116
|
+
result << (result.empty? ? target : [target, result.last + pitch].max)
|
|
117
|
+
end
|
|
118
|
+
shift = mean(targets) - mean(placed)
|
|
119
|
+
|
|
120
|
+
placed.map { |row| row + shift }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def measure
|
|
124
|
+
top = rows.values.min || 0
|
|
125
|
+
rows.transform_values! { |row| (row - top).round + PADDING }
|
|
126
|
+
|
|
127
|
+
@width = (PADDING * 2) + (columns.length * CARD_WIDTH) + ([columns.length - 1, 0].max * COLUMN_GAP)
|
|
128
|
+
@height = (rows.values.max || 0) + card_height + PADDING
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def x(vertex)
|
|
132
|
+
PADDING + (column[vertex] * (CARD_WIDTH + COLUMN_GAP))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def y(vertex)
|
|
136
|
+
rows[vertex]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def left(vertex)
|
|
140
|
+
[x(vertex), y(vertex) + (card_height / 2)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def right(vertex)
|
|
144
|
+
[x(vertex) + CARD_WIDTH, y(vertex) + (card_height / 2)]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def curve(from, to)
|
|
148
|
+
(start_x, start_y) = right(from)
|
|
149
|
+
(end_x, end_y) = left(to)
|
|
150
|
+
middle = (start_x + end_x) / 2
|
|
151
|
+
path = "C#{middle} #{start_y}, #{middle} #{end_y}, #{end_x} #{end_y}"
|
|
152
|
+
|
|
153
|
+
nodes.include?(to) ? path : "#{path} L#{right(to).join(" ")}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def mean(values)
|
|
157
|
+
values.sum.to_f / values.length if values.any?
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|