breadkit-render 0.1.0 → 0.2.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/.yamllint +16 -0
- data/CHANGELOG.md +33 -3
- data/Dockerfile +21 -0
- data/Dockerfile.dockerignore +15 -0
- data/README.md +62 -40
- data/docs/REFERENCE.md +115 -0
- data/docs/images/led-netlist.png +0 -0
- data/docs/images/led-schematic.png +0 -0
- data/lib/breadkit/render/apng_encoder.rb +79 -0
- data/lib/breadkit/render/cli.rb +547 -22
- data/lib/breadkit/render/netlist_renderer.rb +138 -0
- data/lib/breadkit/render/orthogonal_router.rb +137 -0
- data/lib/breadkit/render/rasterizer.rb +138 -18
- data/lib/breadkit/render/schematic_renderer.rb +245 -0
- data/lib/breadkit/render/state_selection.rb +23 -0
- data/lib/breadkit/render/svg_renderer.rb +604 -101
- data/lib/breadkit/render/svg_template.rb +84 -0
- data/lib/breadkit/render/theme.rb +46 -0
- data/lib/breadkit/render/version.rb +1 -1
- data/lib/breadkit/render.rb +7 -0
- data/scripts/check-contrast.mjs +28 -0
- data/sig/breadkit/render.rbs +25 -2
- data/site/images/sensor-demo.png +0 -0
- data/site/images/sensor-demo.svg +8 -8
- data/site/index.html +5 -3
- metadata +36 -10
- data/docs/images/sensor-demo.png +0 -0
- data/docs/images/sensor-demo.svg +0 -45
data/lib/breadkit/render/cli.rb
CHANGED
|
@@ -1,29 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require "cgi/escape"
|
|
5
|
+
require "find"
|
|
4
6
|
|
|
5
7
|
module Breadkit
|
|
6
8
|
module Render
|
|
7
9
|
class CLI
|
|
8
10
|
def run(argv)
|
|
9
|
-
|
|
11
|
+
original_args = argv.dup
|
|
12
|
+
options = { scale: 2.0, theme: "light", orientation: "portrait", color_by: "wire", crop: "auto", backend: "auto", quality: 90,
|
|
13
|
+
view: "breadboard", label_density: "full", wire_routing: "declared", wire_style: "raised" }
|
|
10
14
|
parser = OptionParser.new do |opts|
|
|
11
15
|
opts.banner = "Usage: bkrender [options] INPUT"
|
|
12
16
|
opts.on("-o", "--output PATH") { |value| options[:output] = value }
|
|
13
|
-
opts.on("-f", "--format FORMAT", %w[svg png jpeg jpg]) { |value| options[:format] = value == "jpg" ? "jpeg" : value }
|
|
17
|
+
opts.on("-f", "--format FORMAT", %w[svg html png jpeg jpg webp pdf apng]) { |value| options[:format] = value == "jpg" ? "jpeg" : value }
|
|
18
|
+
opts.on("--view NAME", %w[breadboard netlist schematic]) { |value| options[:view] = value }
|
|
14
19
|
opts.on("--scale N", Float) { |value| options[:scale] = value }
|
|
15
|
-
opts.on("--theme NAME", %w[light dark print]) { |value| options[:theme] = value }
|
|
20
|
+
opts.on("--theme NAME", %w[light dark print colorblind]) { |value| options[:theme] = value }
|
|
21
|
+
opts.on("--theme-file PATH") { |value| options[:theme_file] = value }
|
|
22
|
+
opts.on("--font-file PATH") { |value| options[:font_file] = value }
|
|
16
23
|
opts.on("--orientation NAME", %w[portrait landscape]) { |value| options[:orientation] = value }
|
|
17
24
|
opts.on("--rail-pattern PATTERN", SvgRenderer::RAIL_PATTERNS) { |value| options[:rail_pattern] = value }
|
|
18
25
|
opts.on("--color-by MODE", %w[wire net]) { |value| options[:color_by] = value }
|
|
26
|
+
opts.on("--label-density MODE", SvgRenderer::LABEL_DENSITIES) { |value| options[:label_density] = value }
|
|
27
|
+
opts.on("--wire-routing MODE", %w[declared auto]) { |value| options[:wire_routing] = value }
|
|
28
|
+
opts.on("--wire-style MODE", %w[raised flat]) { |value| options[:wire_style] = value }
|
|
19
29
|
opts.on("--show-nets") { options[:show_nets] = true }
|
|
20
30
|
opts.on("--legend") { options[:legend] = true }
|
|
21
31
|
opts.on("--crop MODE", %w[auto none]) { |value| options[:crop] = value }
|
|
22
32
|
opts.on("--annotations FILE") { |value| options[:annotations] = value }
|
|
23
|
-
opts.on("--
|
|
33
|
+
opts.on("--state NAME") { |value| options[:state] = value }
|
|
34
|
+
opts.on("--step N", Integer) { |value| options[:step] = value }
|
|
35
|
+
opts.on("--assembly-guide") { options[:assembly_guide] = true }
|
|
36
|
+
opts.on("--animate MODE", %w[steps states]) { |value| options[:animate] = value }
|
|
37
|
+
opts.on("--frame-delay MS", Integer) { |value| options[:frame_delay] = value }
|
|
38
|
+
opts.on("--layer NAME") { |value| options[:layer] = value }
|
|
39
|
+
opts.on("--focus REF") { |value| options[:focus] = value }
|
|
40
|
+
opts.on("--highlight-net NAME") { |value| options[:highlight_net] = value }
|
|
41
|
+
opts.on("--diff") { options[:diff] = true }
|
|
42
|
+
opts.on("--backend NAME", %w[auto rsvg resvg vips magick chrome]) { |value| options[:backend] = value }
|
|
24
43
|
opts.on("--background COLOR") { |value| options[:background] = value }
|
|
25
44
|
opts.on("--static") { options[:static] = true }
|
|
45
|
+
opts.on("--watch") { options[:watch] = true }
|
|
26
46
|
opts.on("--quality N", Integer) { |value| options[:quality] = value }
|
|
47
|
+
opts.on("--print-template") { options[:print_template] = true }
|
|
48
|
+
opts.on("--render-timeout SECONDS", Float) { |value| options[:render_timeout] = value }
|
|
27
49
|
opts.on("--force") { options[:force] = true }
|
|
28
50
|
opts.on("-v", "--version") { puts "bkrender #{VERSION}"; return 0 }
|
|
29
51
|
opts.on("-h", "--help") { puts opts; return 0 }
|
|
@@ -31,28 +53,88 @@ module Breadkit
|
|
|
31
53
|
parser.parse!(argv)
|
|
32
54
|
input = argv.shift
|
|
33
55
|
raise ArgumentError, "input file required\n#{parser}" unless input
|
|
56
|
+
second_input = argv.shift if options[:diff]
|
|
57
|
+
raise ArgumentError, "--diff requires OLD and NEW inputs" if options[:diff] && !second_input
|
|
34
58
|
raise ArgumentError, "unexpected arguments: #{argv.join(' ')}" unless argv.empty?
|
|
35
59
|
format = output_format(options)
|
|
60
|
+
if options[:assembly_guide]
|
|
61
|
+
raise ArgumentError, "--assembly-guide requires HTML output" unless format == "html"
|
|
62
|
+
raise ArgumentError, "--assembly-guide requires breadboard view" unless options[:view] == "breadboard"
|
|
63
|
+
if options.values_at(:diff, :step, :state, :layer, :focus, :highlight_net, :annotations, :print_template).any?
|
|
64
|
+
raise ArgumentError, "--assembly-guide cannot select a diff, step, state, layer, focus, annotations, or print template"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
if options[:theme_file]
|
|
68
|
+
custom = Theme.load(options[:theme_file])
|
|
69
|
+
options[:theme], options[:theme_colors] = custom.values_at(:base, :colors)
|
|
70
|
+
end
|
|
71
|
+
options[:font_css] = Theme.font(options[:font_file]) if options[:font_file]
|
|
72
|
+
if options.values_at(:theme_file, :font_file).any? && options[:view] != "breadboard"
|
|
73
|
+
raise ArgumentError, "custom themes and embedded fonts require breadboard view"
|
|
74
|
+
end
|
|
75
|
+
raise ArgumentError, "colorblind theme requires breadboard view" if options[:theme] == "colorblind" && options[:view] != "breadboard"
|
|
76
|
+
raise ArgumentError, "--animate and --frame-delay require APNG output" if format != "apng" && options.values_at(:animate, :frame_delay).any?
|
|
77
|
+
raise ArgumentError, "APNG cannot select a single --state or --step" if format == "apng" && options.values_at(:state, :step).any?
|
|
78
|
+
validate_electrical_view_options(options, format) unless options[:view] == "breadboard"
|
|
79
|
+
validate_step_options(options, format) if options[:step]
|
|
80
|
+
if options[:print_template]
|
|
81
|
+
raise ArgumentError, "--print-template requires PDF output" unless format == "pdf"
|
|
82
|
+
options.merge!(theme: "print", crop: "none", scale: 1.0, static: true)
|
|
83
|
+
end
|
|
36
84
|
raise ArgumentError, "--background is only supported for JPEG" if options[:background] && format != "jpeg"
|
|
37
|
-
raise ArgumentError, "cannot write binary image data to a terminal; use -o PATH" if format
|
|
85
|
+
raise ArgumentError, "cannot write binary image data to a terminal; use -o PATH" if !%w[svg html].include?(format) && !options[:output] && $stdout.tty?
|
|
86
|
+
if options[:watch]
|
|
87
|
+
raise ArgumentError, "--watch requires -o PATH" unless options[:output]
|
|
88
|
+
original_args.delete_at(original_args.index("--watch"))
|
|
89
|
+
return watch(input, second_input, options[:annotations], options[:output], original_args, format)
|
|
90
|
+
end
|
|
91
|
+
return render_diff(input, second_input, options, format) if options[:diff]
|
|
38
92
|
circuit = Breadkit.load(input)
|
|
93
|
+
circuit.diagnostics.each do |item|
|
|
94
|
+
location = [item.location&.path, item.location&.line].compact.join(":")
|
|
95
|
+
warn [location, item.severity || "error", item.message].reject { |value| value.nil? || value.empty? }.join(": ")
|
|
96
|
+
end
|
|
39
97
|
errors = circuit.diagnostics.reject { |item| %w[warning info].include?(item.severity) }
|
|
40
|
-
if !errors.empty? && !options[:force]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
return 1 if !errors.empty? && !options[:force]
|
|
99
|
+
all_steps = circuit.respond_to?(:steps) ? circuit.steps : []
|
|
100
|
+
step = select_step(all_steps, options[:step]) if options[:step]
|
|
101
|
+
circuit = circuit_for_step(circuit, options[:step]) if step
|
|
102
|
+
state = StateSelection.resolve(circuit, options[:state]) if options[:state]
|
|
103
|
+
raise ArgumentError, "unknown circuit state: #{options[:state]}" if options[:state] && !state
|
|
104
|
+
render_options = if %w[netlist schematic].include?(options[:view])
|
|
105
|
+
{ theme: options[:theme], state: state }
|
|
106
|
+
else
|
|
107
|
+
{ crop: step ? "none" : options[:crop], theme: options[:theme], orientation: options[:orientation],
|
|
108
|
+
show_nets: options[:show_nets], legend: options[:legend], color_by: options[:color_by],
|
|
109
|
+
annotations: read_annotations(options[:annotations], input), rail_pattern: options[:rail_pattern],
|
|
110
|
+
interactive_layers: !step && %w[svg html].include?(format) && !options[:static] && !options[:layer],
|
|
111
|
+
state: state, active_layer: options[:layer], focus: options[:focus],
|
|
112
|
+
highlight_net: options[:highlight_net], label_density: options[:label_density],
|
|
113
|
+
theme_colors: options[:theme_colors] || {}, font_css: options[:font_css],
|
|
114
|
+
wire_routing: options[:wire_routing], wire_style: options[:wire_style] }
|
|
115
|
+
end
|
|
116
|
+
renderer = { "breadboard" => SvgRenderer, "netlist" => NetlistRenderer,
|
|
117
|
+
"schematic" => SchematicRenderer }.fetch(options[:view])
|
|
118
|
+
if options[:assembly_guide]
|
|
119
|
+
output = assembly_guide(circuit, render_options, options[:theme])
|
|
120
|
+
options[:output] ? File.binwrite(options[:output], output) : $stdout.write(output)
|
|
121
|
+
return 0
|
|
122
|
+
end
|
|
123
|
+
if format == "apng"
|
|
124
|
+
output = render_animation(circuit, options, render_options)
|
|
125
|
+
options[:output] ? File.binwrite(options[:output], output) : $stdout.write(output)
|
|
126
|
+
return 0
|
|
127
|
+
end
|
|
128
|
+
svg = renderer.new.render(circuit, **render_options)
|
|
129
|
+
svg = add_step_banner(svg, options[:step], step, all_steps, options[:theme]) if step
|
|
130
|
+
svg = print_dimensions(svg) if options[:print_template]
|
|
131
|
+
output = case format
|
|
132
|
+
when "svg" then svg
|
|
133
|
+
when "html" then html_viewer(svg, theme: options[:theme], circuit: circuit, render_options: render_options)
|
|
53
134
|
else
|
|
54
135
|
Rasterizer.new.rasterize(svg, format: format, scale: options[:scale],
|
|
55
|
-
background: options[:background] || "white", quality: options[:quality], backend: options[:backend]
|
|
136
|
+
background: options[:background] || "white", quality: options[:quality], backend: options[:backend],
|
|
137
|
+
timeout: options[:render_timeout] || 60)
|
|
56
138
|
end
|
|
57
139
|
options[:output] ? File.binwrite(options[:output], output) : $stdout.write(output)
|
|
58
140
|
0
|
|
@@ -66,14 +148,319 @@ module Breadkit
|
|
|
66
148
|
|
|
67
149
|
private
|
|
68
150
|
|
|
151
|
+
def assembly_guide(circuit, render_options, theme)
|
|
152
|
+
steps = circuit.respond_to?(:steps) ? circuit.steps : []
|
|
153
|
+
raise ArgumentError, "circuit has no assembly steps" if steps.empty?
|
|
154
|
+
|
|
155
|
+
physical_wires = circuit.wires.reject { |wire| wire.electrical == false }
|
|
156
|
+
items = circuit.components.values.group_by { |component| [component.part.id, bom_value(component)] }
|
|
157
|
+
rows = items.sort_by { |(part, value), _| [part, value.to_s] }.map do |(part, value), components|
|
|
158
|
+
cells = [components.length, part, value, components.map(&:ref).sort.join(", ")]
|
|
159
|
+
"<tr>#{cells.map { |cell| "<td>#{CGI.escapeHTML(cell.to_s)}</td>" }.join}</tr>"
|
|
160
|
+
end
|
|
161
|
+
rows << "<tr><td>#{physical_wires.length}</td><td>Jumper wire</td><td></td><td></td></tr>"
|
|
162
|
+
panels = steps.each_with_index.map do |step, index|
|
|
163
|
+
number = index + 1
|
|
164
|
+
stage = circuit_for_step(circuit, number, physical_only: true)
|
|
165
|
+
svg = SvgRenderer.new.render(stage, **render_options.merge(crop: "none", interactive_layers: false))
|
|
166
|
+
.sub(/\A<\?xml[^>]*\?>\s*/, "")
|
|
167
|
+
heading = CGI.escapeHTML(step[:title] || "Assembly step")
|
|
168
|
+
added_parts = circuit.components.values.select { |component| component.step == number }.map(&:ref).sort
|
|
169
|
+
added_wires = physical_wires.select { |wire| wire.step == number }.map { |wire| "#{wire.from} → #{wire.to}" }
|
|
170
|
+
changes = []
|
|
171
|
+
changes << "Place #{added_parts.join(', ')}" unless added_parts.empty?
|
|
172
|
+
changes.concat(added_wires.map { |wire| "Connect #{wire}" })
|
|
173
|
+
instructions = changes.map { |change| "<li>#{CGI.escapeHTML(change)}</li>" }.join
|
|
174
|
+
%(<section class="step"><h2>Step #{number} of #{steps.length}: #{heading}</h2><ul>#{instructions}</ul><div class="diagram">#{svg}</div></section>)
|
|
175
|
+
end
|
|
176
|
+
dark = theme == "dark"
|
|
177
|
+
background, surface, foreground, muted = dark ? %w[#111a16 #1d2822 #ecf5ef #b7c9bd] : %w[#f5f7f4 #ffffff #1c3026 #536a5b]
|
|
178
|
+
title = CGI.escapeHTML(circuit.title || "Breadkit assembly guide")
|
|
179
|
+
<<~HTML
|
|
180
|
+
<!doctype html>
|
|
181
|
+
<html lang="en">
|
|
182
|
+
<head>
|
|
183
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
184
|
+
<title>#{title}</title>
|
|
185
|
+
<style>
|
|
186
|
+
*{box-sizing:border-box}body{margin:0;background:#{background};color:#{foreground};font:16px/1.5 system-ui,sans-serif}
|
|
187
|
+
main{max-width:1120px;margin:auto;padding:32px 20px 64px}h1{font-size:clamp(28px,4vw,44px);line-height:1.1;margin:0 0 10px}
|
|
188
|
+
.intro{color:#{muted};margin:0 0 32px}h2{font-size:22px;line-height:1.25;margin:0 0 16px}
|
|
189
|
+
section{background:#{surface};border:1px solid #{muted};border-radius:14px;padding:24px;margin:0 0 24px}
|
|
190
|
+
table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:10px 12px;border-bottom:1px solid #{muted}}
|
|
191
|
+
th{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#{muted}}tr:last-child td{border-bottom:0}
|
|
192
|
+
ul{margin:0 0 20px;padding-left:22px}.diagram{overflow:auto}.diagram svg{display:block;width:min(100%,560px);height:auto;margin:auto}
|
|
193
|
+
@media(max-width:600px){main{padding:20px 12px}section{padding:16px}th,td{padding:8px 5px;font-size:13px}}
|
|
194
|
+
@media print{body{background:white;color:black}main{max-width:none;padding:0}section{break-inside:avoid;border-color:#999}.step{break-before:page}}
|
|
195
|
+
</style>
|
|
196
|
+
</head>
|
|
197
|
+
<body><main><h1>#{title}</h1><p class="intro">#{steps.length} assembly steps · #{circuit.components.length} parts · #{physical_wires.length} jumper #{physical_wires.length == 1 ? 'wire' : 'wires'}</p>
|
|
198
|
+
<section><h2>Bill of materials</h2><table id="bom"><thead><tr><th>Qty</th><th>Part</th><th>Value</th><th>Refs</th></tr></thead><tbody>#{rows.join}</tbody></table></section>
|
|
199
|
+
#{panels.join("\n")}
|
|
200
|
+
</main></body></html>
|
|
201
|
+
HTML
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def bom_value(component)
|
|
205
|
+
return nil unless component.value
|
|
206
|
+
|
|
207
|
+
Breadkit::Value.new(component.value, category: component.part.data.dig("render", "shape")).to_s
|
|
208
|
+
rescue ArgumentError
|
|
209
|
+
component.value.to_s
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def render_animation(circuit, options, render_options)
|
|
213
|
+
raise ArgumentError, "APNG is available only in breadboard view" unless options[:view] == "breadboard"
|
|
214
|
+
|
|
215
|
+
steps = circuit.respond_to?(:steps) ? circuit.steps : []
|
|
216
|
+
mode = options[:animate] || (steps.empty? ? "states" : "steps")
|
|
217
|
+
if mode == "steps" && options.values_at(:annotations, :focus, :highlight_net, :layer).any?
|
|
218
|
+
raise ArgumentError, "APNG assembly steps cannot use annotations, focus, highlighted nets, or a layer filter"
|
|
219
|
+
end
|
|
220
|
+
frames = if mode == "steps"
|
|
221
|
+
steps.each_index.map do |index|
|
|
222
|
+
number = index + 1
|
|
223
|
+
svg = SvgRenderer.new.render(circuit_for_step(circuit, number), **render_options.merge(crop: "none", interactive_layers: false))
|
|
224
|
+
add_step_banner(svg, number, steps[index], steps, options[:theme])
|
|
225
|
+
end
|
|
226
|
+
else
|
|
227
|
+
circuit.states("all", budget: 256).map do |state|
|
|
228
|
+
SvgRenderer.new.render(circuit, **render_options.merge(crop: "none", state: state, interactive_layers: false))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
raise ArgumentError, "APNG requires at least two #{mode}" unless frames.length >= 2
|
|
232
|
+
|
|
233
|
+
rasterizer = Rasterizer.new
|
|
234
|
+
images = frames.map do |svg|
|
|
235
|
+
rasterizer.rasterize(svg, format: "png", scale: options[:scale], backend: options[:backend],
|
|
236
|
+
timeout: options[:render_timeout] || 60)
|
|
237
|
+
end
|
|
238
|
+
ApngEncoder.new.encode(images, delay_ms: options[:frame_delay] || 800)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def watch(input, second_input, annotations, output, args, format)
|
|
242
|
+
root = File.dirname(File.expand_path(input))
|
|
243
|
+
paths = [input, second_input, annotations].compact.map { |path| File.expand_path(path) }
|
|
244
|
+
snapshot = watch_snapshot(root, paths, output)
|
|
245
|
+
warn "bkrender: watching #{root}; press Ctrl-C to stop"
|
|
246
|
+
loop do
|
|
247
|
+
if run(args.dup).zero?
|
|
248
|
+
write_live_reload(output) if format == "html"
|
|
249
|
+
warn "bkrender: rendered #{output}"
|
|
250
|
+
end
|
|
251
|
+
loop do
|
|
252
|
+
sleep 0.5
|
|
253
|
+
updated = watch_snapshot(root, paths, output)
|
|
254
|
+
next if updated == snapshot
|
|
255
|
+
|
|
256
|
+
snapshot = updated
|
|
257
|
+
break
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
rescue Interrupt
|
|
261
|
+
0
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def write_live_reload(output)
|
|
265
|
+
revision = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s
|
|
266
|
+
sidecar = "#{output}.reload.js"
|
|
267
|
+
filename = JSON.generate(File.basename(sidecar)).gsub("<", "\\u003c")
|
|
268
|
+
script = <<~HTML
|
|
269
|
+
<script>
|
|
270
|
+
(() => {
|
|
271
|
+
const revision = #{JSON.generate(revision)};
|
|
272
|
+
const file = #{filename};
|
|
273
|
+
window.__breadkitWatch = incoming => { if (incoming !== revision) location.reload(); };
|
|
274
|
+
setInterval(() => {
|
|
275
|
+
const tag = document.createElement('script');
|
|
276
|
+
tag.src = encodeURIComponent(file) + '?t=' + Date.now();
|
|
277
|
+
tag.onload = tag.onerror = () => tag.remove();
|
|
278
|
+
document.head.append(tag);
|
|
279
|
+
}, 1000);
|
|
280
|
+
})();
|
|
281
|
+
</script>
|
|
282
|
+
HTML
|
|
283
|
+
html = File.binread(output)
|
|
284
|
+
raise Error, "cannot add live reload to HTML output" unless html.match?(%r{</body>}i)
|
|
285
|
+
|
|
286
|
+
File.binwrite(output, html.sub(%r{</body>}i) { "#{script}</body>" })
|
|
287
|
+
File.binwrite(sidecar, "window.__breadkitWatch(#{JSON.generate(revision)});\n")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def watch_snapshot(root, paths, output)
|
|
291
|
+
# ponytail: watch nearby circuit and part files; add dependency tracking if broad projects make scanning costly.
|
|
292
|
+
files = paths.dup
|
|
293
|
+
Find.find(root) do |path|
|
|
294
|
+
if File.directory?(path)
|
|
295
|
+
Find.prune if path != root && (File.basename(path).start_with?(".") || File.basename(path) == "node_modules")
|
|
296
|
+
elsif path.end_with?(".bk.rb", ".yml", ".yaml", ".toml", ".json")
|
|
297
|
+
files << path
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
files.map { |path| File.expand_path(path) }.uniq.sort.reject { |path| path == File.expand_path(output) }.to_h do |path|
|
|
301
|
+
stat = File.stat(path)
|
|
302
|
+
[path, [stat.mtime.to_r, stat.size]]
|
|
303
|
+
rescue Errno::ENOENT
|
|
304
|
+
[path, nil]
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def validate_step_options(options, format)
|
|
309
|
+
raise ArgumentError, "--step must be a positive integer" unless options[:step].positive?
|
|
310
|
+
raise ArgumentError, "--step is unavailable in #{options[:view]} view" unless options[:view] == "breadboard"
|
|
311
|
+
raise ArgumentError, "--step requires SVG or image output" if format == "html"
|
|
312
|
+
raise ArgumentError, "--step is unavailable with --diff" if options[:diff]
|
|
313
|
+
raise ArgumentError, "--step is unavailable with --print-template" if options[:print_template]
|
|
314
|
+
raise ArgumentError, "--step is unavailable with --annotations" if options[:annotations]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def select_step(steps, number)
|
|
318
|
+
raise ArgumentError, "circuit has no assembly steps" if steps.empty?
|
|
319
|
+
|
|
320
|
+
steps[number - 1] || raise(ArgumentError, "unknown assembly step: #{number}")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def circuit_for_step(circuit, number, physical_only: false)
|
|
324
|
+
visible = ->(item) { !item.step || item.step <= number }
|
|
325
|
+
components = circuit.components.select { |_ref, component| visible.call(component) }
|
|
326
|
+
wires = circuit.wires.select { |wire| visible.call(wire) && (!physical_only || wire.electrical != false) }
|
|
327
|
+
supplies = circuit.supplies.select(&visible)
|
|
328
|
+
labels = circuit.labels.select(&visible)
|
|
329
|
+
future_refs = (circuit.components.keys - components.keys) + (circuit.supplies.map(&:name) - supplies.map(&:name))
|
|
330
|
+
(wires.flat_map { |wire| [wire.from, wire.to] } + labels.map(&:at)).each do |endpoint|
|
|
331
|
+
ref = endpoint.split(".", 2).first if endpoint.include?(".")
|
|
332
|
+
raise ArgumentError, "step #{number} references #{endpoint} before #{ref} is placed" if future_refs.include?(ref)
|
|
333
|
+
end
|
|
334
|
+
Breadkit::Circuit.new(title: circuit.title, board: circuit.board, components: components, wires: wires,
|
|
335
|
+
supplies: supplies, labels: labels, expectations: [], lint_disables: [], diagnostics: [],
|
|
336
|
+
steps: circuit.steps.take(number), source_root: circuit.source_root)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def add_step_banner(svg, number, step, all_steps, theme)
|
|
340
|
+
inner = svg.sub(/\A<\?xml[^>]*\?>\s*/, "")
|
|
341
|
+
match = inner.match(/\A<svg\b[^>]*\bwidth="([\d.]+)" height="([\d.]+)"/)
|
|
342
|
+
raise Error, "cannot add an assembly step title to this SVG" unless match
|
|
343
|
+
|
|
344
|
+
width, height = match.captures.map(&:to_f)
|
|
345
|
+
max_chars = [(width - 24).div(7), 12].max
|
|
346
|
+
titles = all_steps.map { |item| wrap_step_title(item[:title] || "Assembly step", max_chars) }
|
|
347
|
+
banner_height = 50 + titles.map(&:length).max * 18
|
|
348
|
+
heading = "Step #{number} of #{all_steps.length}: #{step[:title] || 'Assembly step'}"
|
|
349
|
+
background, foreground = theme == "dark" ? %w[#0d1420 #f2f6fc] : %w[#f4f7fb #172338]
|
|
350
|
+
inner = inner.sub("<svg ", %(<svg id="step-board" x="0" y="#{banner_height}" ))
|
|
351
|
+
title_lines = [%(<text x="20" y="27" fill="#{foreground}" font-size="15" font-weight="700">Step #{number} of #{all_steps.length}</text>)]
|
|
352
|
+
titles[number - 1].each_with_index do |line, index|
|
|
353
|
+
title_lines << %(<text x="20" y="#{52 + index * 18}" fill="#{foreground}" font-size="12">#{CGI.escapeHTML(line)}</text>)
|
|
354
|
+
end
|
|
355
|
+
%(<?xml version="1.0" encoding="UTF-8"?>\n) +
|
|
356
|
+
%(<svg xmlns="http://www.w3.org/2000/svg" width="#{format('%.2f', width)}" height="#{format('%.2f', height + banner_height)}" viewBox="0 0 #{format('%.2f', width)} #{format('%.2f', height + banner_height)}" role="img" aria-label="#{CGI.escapeHTML(heading)}" data-step="#{number}">\n) +
|
|
357
|
+
%(<title>#{CGI.escapeHTML(heading)}</title><rect width="#{format('%.2f', width)}" height="#{banner_height}" fill="#{background}"/>\n) +
|
|
358
|
+
%(<g id="assembly-step-title" font-family="Helvetica, Arial, sans-serif">#{title_lines.join}</g>\n) +
|
|
359
|
+
%(#{inner}\n</svg>)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def wrap_step_title(title, max_chars)
|
|
363
|
+
words = title.split.flat_map { |word| word.scan(/.{1,#{max_chars}}/) }
|
|
364
|
+
words.each_with_object([""]) do |word, lines|
|
|
365
|
+
if lines.last.empty? || lines.last.length + word.length + 1 <= max_chars
|
|
366
|
+
lines[-1] = [lines.last, word].reject(&:empty?).join(" ")
|
|
367
|
+
else
|
|
368
|
+
lines << word
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def validate_electrical_view_options(options, format)
|
|
374
|
+
view = options[:view]
|
|
375
|
+
raise ArgumentError, "HTML is unavailable in #{view} view" if format == "html"
|
|
376
|
+
|
|
377
|
+
unavailable = { diff: options[:diff], print_template: options[:print_template], rail_pattern: options[:rail_pattern],
|
|
378
|
+
annotations: options[:annotations], layer: options[:layer], focus: options[:focus],
|
|
379
|
+
highlight_net: options[:highlight_net], show_nets: options[:show_nets], legend: options[:legend],
|
|
380
|
+
static: options[:static], crop: options[:crop] != "auto", orientation: options[:orientation] != "portrait",
|
|
381
|
+
color_by: options[:color_by] != "wire", label_density: options[:label_density] != "full",
|
|
382
|
+
wire_routing: options[:wire_routing] != "declared", wire_style: options[:wire_style] != "raised" }
|
|
383
|
+
option = unavailable.find { |_name, used| used }&.first
|
|
384
|
+
raise ArgumentError, "--#{option.to_s.tr('_', '-')} is unavailable in #{view} view" if option
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def print_dimensions(svg)
|
|
388
|
+
svg.sub(/(<svg\b[^>]*?\bwidth=")([\d.]+)(" height=")([\d.]+)(")/) do
|
|
389
|
+
"#{$1}#{format('%.2f', $2.to_f * 0.254)}mm#{$3}#{format('%.2f', $4.to_f * 0.254)}mm#{$5}"
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
69
393
|
def output_format(options)
|
|
70
394
|
extension = File.extname(options[:output].to_s).downcase
|
|
71
|
-
from_path = { ".svg" => "svg", ".png" => "png", ".jpg" => "jpeg", ".jpeg" => "jpeg"
|
|
72
|
-
|
|
395
|
+
from_path = { ".svg" => "svg", ".html" => "html", ".png" => "png", ".jpg" => "jpeg", ".jpeg" => "jpeg",
|
|
396
|
+
".webp" => "webp", ".pdf" => "pdf", ".apng" => "apng" }[extension]
|
|
397
|
+
raise ArgumentError, "unsupported output extension: #{extension}" if options[:output] && !from_path && !options[:format]
|
|
73
398
|
if options[:format] && from_path && options[:format] != from_path
|
|
74
399
|
raise ArgumentError, "--format conflicts with output extension"
|
|
75
400
|
end
|
|
76
|
-
options[:format] || from_path || "svg"
|
|
401
|
+
options[:format] || from_path || (options[:diff] ? "html" : "svg")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def render_diff(before_path, after_path, options, format)
|
|
405
|
+
raise ArgumentError, "--diff requires HTML output" unless format == "html"
|
|
406
|
+
raise ArgumentError, "--diff does not support annotations, state, layer, or focus selection" if options.values_at(:annotations, :state, :layer, :focus, :highlight_net).any?
|
|
407
|
+
|
|
408
|
+
before, after = [before_path, after_path].map { |path| Breadkit.load(path) }
|
|
409
|
+
[before, after].zip([before_path, after_path]).each do |circuit, path|
|
|
410
|
+
circuit.diagnostics.each { |item| warn "#{path}: #{item.severity || 'error'}: #{item.message}" }
|
|
411
|
+
end
|
|
412
|
+
return 1 if !options[:force] && [before, after].any? { |circuit| circuit.diagnostics.any? { |item| !%w[warning info].include?(item.severity) } }
|
|
413
|
+
|
|
414
|
+
removed = unmatched_wires(before, after).to_h { |id| [id, "removed"] }
|
|
415
|
+
added = unmatched_wires(after, before).to_h { |id| [id, "added"] }
|
|
416
|
+
render_options = { crop: options[:crop], theme: options[:theme], orientation: options[:orientation],
|
|
417
|
+
show_nets: options[:show_nets], legend: options[:legend], color_by: options[:color_by],
|
|
418
|
+
rail_pattern: options[:rail_pattern], interactive_layers: !options[:static],
|
|
419
|
+
label_density: options[:label_density], theme_colors: options[:theme_colors] || {},
|
|
420
|
+
font_css: options[:font_css], wire_routing: options[:wire_routing],
|
|
421
|
+
wire_style: options[:wire_style] }
|
|
422
|
+
old_svg = SvgRenderer.new.render(before, **render_options, diff_wires: removed)
|
|
423
|
+
new_svg = SvgRenderer.new.render(after, **render_options, diff_wires: added)
|
|
424
|
+
html = diff_viewer(old_svg, new_svg, theme: options[:theme])
|
|
425
|
+
options[:output] ? File.binwrite(options[:output], html) : $stdout.write(html)
|
|
426
|
+
0
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def unmatched_wires(first, second)
|
|
430
|
+
remaining = second.wires.map { |wire| wire_signature(wire) }.tally
|
|
431
|
+
first.wires.filter_map do |wire|
|
|
432
|
+
signature = wire_signature(wire)
|
|
433
|
+
if remaining.fetch(signature, 0).positive?
|
|
434
|
+
remaining[signature] -= 1
|
|
435
|
+
nil
|
|
436
|
+
else
|
|
437
|
+
wire.id
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def wire_signature(wire)
|
|
443
|
+
[*([wire.from, wire.to].sort), wire.color, wire.route, wire.layer, wire.electrical, wire.dashed]
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def diff_viewer(old_svg, new_svg, theme:)
|
|
447
|
+
background = theme == "dark" ? "#151d19" : "#f1f4f1"
|
|
448
|
+
foreground = theme == "dark" ? "#ecf3ee" : "#203029"
|
|
449
|
+
before = CGI.escapeHTML(html_viewer(old_svg, theme: theme))
|
|
450
|
+
after = CGI.escapeHTML(html_viewer(new_svg, theme: theme))
|
|
451
|
+
<<~HTML
|
|
452
|
+
<!doctype html>
|
|
453
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
454
|
+
<title>Breadkit wiring diff</title><style>
|
|
455
|
+
*{box-sizing:border-box}body{margin:0;background:#{background};color:#{foreground};font:14px system-ui,sans-serif}
|
|
456
|
+
main{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding:8px}section{min-width:0}
|
|
457
|
+
h2{margin:0 0 8px;font-size:14px;font-weight:600}iframe{width:100%;height:calc(100vh - 48px);border:1px solid currentColor;border-radius:8px}
|
|
458
|
+
@media(max-width:700px){main{grid-template-columns:1fr}iframe{height:70vh}}
|
|
459
|
+
</style></head><body><main>
|
|
460
|
+
<section><h2>Before · removed wires in red</h2><iframe title="Before" sandbox="allow-scripts" srcdoc="#{before}"></iframe></section>
|
|
461
|
+
<section><h2>After · added wires in green</h2><iframe title="After" sandbox="allow-scripts" srcdoc="#{after}"></iframe></section>
|
|
462
|
+
</main></body></html>
|
|
463
|
+
HTML
|
|
77
464
|
end
|
|
78
465
|
|
|
79
466
|
def read_annotations(path, input)
|
|
@@ -85,6 +472,144 @@ module Breadkit
|
|
|
85
472
|
match ||= files.first if files.length == 1
|
|
86
473
|
Array(match && match["offenses"])
|
|
87
474
|
end
|
|
475
|
+
|
|
476
|
+
def html_viewer(svg, theme:, circuit: nil, render_options: nil)
|
|
477
|
+
background = theme == "dark" ? "#151d19" : "#f1f4f1"
|
|
478
|
+
foreground = theme == "dark" ? "#ecf3ee" : "#203029"
|
|
479
|
+
states = circuit&.states("all", budget: 256) || []
|
|
480
|
+
selected = render_options&.dig(:state)&.name.to_s
|
|
481
|
+
diagram = viewer_diagrams(svg, states, selected) { |state| SvgRenderer.new.render(circuit, **render_options.merge(state: state)) }
|
|
482
|
+
schematic = if circuit
|
|
483
|
+
initial = SchematicRenderer.new.render(circuit, theme: theme, state: render_options[:state])
|
|
484
|
+
viewer_diagrams(initial, states, selected) { |state| SchematicRenderer.new.render(circuit, theme: theme, state: state) }
|
|
485
|
+
end
|
|
486
|
+
state_control = ""
|
|
487
|
+
if states.length > 1
|
|
488
|
+
options = states.map do |state|
|
|
489
|
+
name = state.name.to_s
|
|
490
|
+
"<option value=\"#{CGI.escapeHTML(name)}\"#{' selected' if name == selected}>#{CGI.escapeHTML(name.empty? ? 'Open' : name)}</option>"
|
|
491
|
+
end.join
|
|
492
|
+
state_control = "<label for=\"state\">State</label><select id=\"state\" aria-label=\"Switch state\">#{options}</select>"
|
|
493
|
+
end
|
|
494
|
+
schematic_panel = if schematic
|
|
495
|
+
<<~PANEL
|
|
496
|
+
<section class="diagram-panel" aria-labelledby="schematic-heading">
|
|
497
|
+
<div class="panel-header"><h2 id="schematic-heading">Schematic</h2><div class="zoom-controls">
|
|
498
|
+
<button type="button" data-pane="schematic" data-action="in" aria-label="Zoom in schematic">+</button>
|
|
499
|
+
<button type="button" data-pane="schematic" data-action="out" aria-label="Zoom out schematic">−</button>
|
|
500
|
+
<button type="button" data-pane="schematic" data-action="fit" aria-label="Fit schematic">Fit</button>
|
|
501
|
+
</div></div>
|
|
502
|
+
<div id="schematic-viewport" class="viewport" tabindex="0" aria-label="Schematic diagram. Arrow keys pan; plus and minus zoom."><div id="schematic-scene" class="scene">#{schematic}</div></div>
|
|
503
|
+
</section>
|
|
504
|
+
PANEL
|
|
505
|
+
end
|
|
506
|
+
<<~HTML
|
|
507
|
+
<!doctype html>
|
|
508
|
+
<html lang="en">
|
|
509
|
+
<head>
|
|
510
|
+
<meta charset="utf-8">
|
|
511
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
512
|
+
<title>Breadkit diagram</title>
|
|
513
|
+
<style>
|
|
514
|
+
*{box-sizing:border-box}body{margin:0;height:100dvh;display:flex;flex-direction:column;background:#{background};color:#{foreground};font:14px system-ui,sans-serif}
|
|
515
|
+
#diagrams{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding:8px;flex:1;min-height:0}
|
|
516
|
+
#diagrams.single{grid-template-columns:1fr}.diagram-panel{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid currentColor;border-radius:8px;overflow:hidden}
|
|
517
|
+
.panel-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 8px;border-bottom:1px solid currentColor}
|
|
518
|
+
h2{margin:0;font-size:14px;font-weight:600}.zoom-controls{display:flex;gap:2px}
|
|
519
|
+
.viewport{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none;cursor:grab}.viewport.dragging{cursor:grabbing}.viewport:focus-visible{outline:2px solid currentColor;outline-offset:-2px}
|
|
520
|
+
.scene{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}.scene svg{display:block;max-width:none}
|
|
521
|
+
.scene svg[data-viewer-state]:not([data-active]){display:none}
|
|
522
|
+
.net-muted{opacity:.18!important}
|
|
523
|
+
#toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid currentColor;background:#{background}}
|
|
524
|
+
button{color:inherit;background:transparent;border:0;border-radius:4px;min-width:36px;height:32px;font:inherit;cursor:pointer}button:hover,button:focus-visible{outline:2px solid currentColor}
|
|
525
|
+
select{color:inherit;background:#{background};border:1px solid currentColor;border-radius:4px;font:inherit;max-width:220px;padding:4px}
|
|
526
|
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
527
|
+
[data-switch]{cursor:pointer}[data-switch]:focus-visible{filter:drop-shadow(0 0 3px currentColor)}
|
|
528
|
+
@media(max-width:850px){body{height:auto;min-height:100dvh}#diagrams.dual{grid-template-columns:1fr;flex:none}.dual .diagram-panel{height:68vh}}
|
|
529
|
+
</style>
|
|
530
|
+
</head>
|
|
531
|
+
<body>
|
|
532
|
+
<div id="toolbar" role="toolbar" aria-label="Diagram controls">
|
|
533
|
+
#{state_control}
|
|
534
|
+
<label for="net">Net</label><select id="net" aria-label="Highlight net"><option value="">All nets</option></select>
|
|
535
|
+
</div>
|
|
536
|
+
<main id="diagrams" class="#{schematic ? 'dual' : 'single'}">
|
|
537
|
+
<section class="diagram-panel" aria-labelledby="breadboard-heading">
|
|
538
|
+
<div class="panel-header"><h2 id="breadboard-heading">Breadboard</h2><div class="zoom-controls">
|
|
539
|
+
<button type="button" data-pane="board" data-action="in" aria-label="Zoom in breadboard">+</button>
|
|
540
|
+
<button type="button" data-pane="board" data-action="out" aria-label="Zoom out breadboard">−</button>
|
|
541
|
+
<button type="button" data-pane="board" data-action="fit" aria-label="Fit breadboard">Fit</button>
|
|
542
|
+
</div></div>
|
|
543
|
+
<div id="viewport" class="viewport" tabindex="0" aria-label="Interactive breadboard diagram. Arrow keys pan; plus and minus zoom."><div id="scene" class="scene">#{diagram}</div></div>
|
|
544
|
+
</section>
|
|
545
|
+
#{schematic_panel}
|
|
546
|
+
</main>
|
|
547
|
+
<span id="viewer-status" class="sr-only" role="status"></span>
|
|
548
|
+
<script>
|
|
549
|
+
const stateSelect=document.getElementById('state'),netSelect=document.getElementById('net'),status=document.getElementById('viewer-status');
|
|
550
|
+
let selectedNet='',hoverNet='';
|
|
551
|
+
const panes={};
|
|
552
|
+
const makePane=(key,viewportId,sceneId)=>{
|
|
553
|
+
const viewport=document.getElementById(viewportId),scene=document.getElementById(sceneId);
|
|
554
|
+
let svg=scene.querySelector('[data-active]')||scene.querySelector('svg'),scale=1,x=0,y=0,dragging=false,lastX=0,lastY=0;
|
|
555
|
+
const paint=()=>{scene.style.transform=`translate(${x}px,${y}px) scale(${scale})`};
|
|
556
|
+
const fit=()=>{const w=Number(svg.getAttribute('width')),h=Number(svg.getAttribute('height'));
|
|
557
|
+
scene.style.width=`${w}px`;scene.style.height=`${h}px`;
|
|
558
|
+
scale=Math.min((viewport.clientWidth-32)/w,(viewport.clientHeight-32)/h);x=(viewport.clientWidth-w*scale)/2;y=(viewport.clientHeight-h*scale)/2;paint()};
|
|
559
|
+
const zoom=(factor,cx,cy)=>{const next=Math.max(.1,Math.min(12,scale*factor));x=cx-(cx-x)*next/scale;y=cy-(cy-y)*next/scale;scale=next;paint()};
|
|
560
|
+
const showState=name=>{const next=[...scene.querySelectorAll('[data-viewer-state]')].find(node=>node.getAttribute('data-viewer-state')===name);
|
|
561
|
+
if(!next)return;svg.removeAttribute('data-active');svg=next;svg.setAttribute('data-active','');fit()};
|
|
562
|
+
viewport.addEventListener('wheel',event=>{event.preventDefault();const box=viewport.getBoundingClientRect();zoom(event.deltaY<0?1.15:1/1.15,event.clientX-box.left,event.clientY-box.top)},{passive:false});
|
|
563
|
+
viewport.addEventListener('pointerdown',event=>{if(event.target.closest('[data-layer-button],[data-switch]'))return;dragging=true;lastX=event.clientX;lastY=event.clientY;viewport.classList.add('dragging');viewport.setPointerCapture(event.pointerId)});
|
|
564
|
+
viewport.addEventListener('pointermove',event=>{if(dragging){x+=event.clientX-lastX;y+=event.clientY-lastY;lastX=event.clientX;lastY=event.clientY;paint();return}
|
|
565
|
+
const net=event.target.closest('[data-net]')?.getAttribute('data-net')||'';if(net===hoverNet)return;hoverNet=net;paintNet()});
|
|
566
|
+
const endDrag=()=>{dragging=false;viewport.classList.remove('dragging')};viewport.addEventListener('pointerup',endDrag);viewport.addEventListener('pointercancel',endDrag);
|
|
567
|
+
viewport.addEventListener('pointerleave',()=>{if(dragging)return;hoverNet='';paintNet()});
|
|
568
|
+
viewport.addEventListener('keydown',event=>{if(event.defaultPrevented)return;
|
|
569
|
+
if(event.key==='+'||event.key==='=')zoom(1.25,viewport.clientWidth/2,viewport.clientHeight/2);
|
|
570
|
+
else if(event.key==='-')zoom(.8,viewport.clientWidth/2,viewport.clientHeight/2);
|
|
571
|
+
else if(event.key==='0')fit();
|
|
572
|
+
else if(event.key==='ArrowLeft')x+=24;else if(event.key==='ArrowRight')x-=24;
|
|
573
|
+
else if(event.key==='ArrowUp')y+=24;else if(event.key==='ArrowDown')y-=24;else return;
|
|
574
|
+
paint();event.preventDefault()});
|
|
575
|
+
panes[key]={scene,active:()=>svg,fit,zoom,showState};
|
|
576
|
+
};
|
|
577
|
+
makePane('board','viewport','scene');
|
|
578
|
+
if(document.getElementById('schematic-viewport'))makePane('schematic','schematic-viewport','schematic-scene');
|
|
579
|
+
const paintNet=()=>{const net=hoverNet||selectedNet;
|
|
580
|
+
Object.values(panes).forEach(pane=>pane.active().querySelectorAll('[data-net]').forEach(node=>node.classList.toggle('net-muted',!!net&&node.getAttribute('data-net')!==net)))};
|
|
581
|
+
const populateNets=()=>{const names=[...new Set(Object.values(panes).flatMap(pane=>[...pane.active().querySelectorAll('[data-net]')].map(node=>node.getAttribute('data-net'))))].sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}));
|
|
582
|
+
netSelect.replaceChildren(new Option('All nets',''),...names.map(name=>new Option(name,name)));selectedNet='';netSelect.value='';paintNet()};
|
|
583
|
+
const selectState=name=>{Object.values(panes).forEach(pane=>pane.showState(name));stateSelect.value=name;hoverNet='';populateNets();
|
|
584
|
+
status.textContent=`${name||'Open'} switch state selected`};
|
|
585
|
+
if(stateSelect){const board=panes.board.scene;
|
|
586
|
+
board.querySelectorAll('[data-switch]').forEach(node=>{node.setAttribute('tabindex','0');node.setAttribute('role','button');node.setAttribute('aria-label',`Toggle ${node.getAttribute('data-switch')}`)});
|
|
587
|
+
const switchRefs=[...new Set([...panes.board.active().querySelectorAll('[data-switch]')].map(node=>node.getAttribute('data-switch')))];
|
|
588
|
+
const toggleSwitch=ref=>{const active=new Set(stateSelect.value.split(',').filter(Boolean));active.has(ref)?active.delete(ref):active.add(ref);
|
|
589
|
+
selectState(switchRefs.filter(name=>active.has(name)).join(','));[...panes.board.active().querySelectorAll('[data-switch]')].find(node=>node.getAttribute('data-switch')===ref)?.focus()};
|
|
590
|
+
stateSelect.addEventListener('change',()=>selectState(stateSelect.value));
|
|
591
|
+
board.addEventListener('click',event=>{const button=event.target.closest('[data-switch]');if(button)toggleSwitch(button.getAttribute('data-switch'))});
|
|
592
|
+
board.addEventListener('keydown',event=>{const button=event.target.closest('[data-switch]');if(!button||(event.key!=='Enter'&&event.key!==' '))return;
|
|
593
|
+
event.preventDefault();toggleSwitch(button.getAttribute('data-switch'))})}
|
|
594
|
+
netSelect.addEventListener('change',()=>{selectedNet=netSelect.value;paintNet();status.textContent=selectedNet?`${selectedNet} highlighted in both diagrams`:'All nets shown'});
|
|
595
|
+
document.querySelectorAll('[data-action]').forEach(button=>button.addEventListener('click',()=>{const pane=panes[button.getAttribute('data-pane')],action=button.getAttribute('data-action');
|
|
596
|
+
if(action==='fit')pane.fit();else pane.zoom(action==='in'?1.25:.8,pane.scene.parentElement.clientWidth/2,pane.scene.parentElement.clientHeight/2)}));
|
|
597
|
+
window.addEventListener('resize',()=>Object.values(panes).forEach(pane=>pane.fit()));populateNets();Object.values(panes).forEach(pane=>pane.fit());
|
|
598
|
+
</script>
|
|
599
|
+
</body>
|
|
600
|
+
</html>
|
|
601
|
+
HTML
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def viewer_diagrams(original, states, selected)
|
|
605
|
+
return original.sub(/\A<\?xml[^>]*\?>\s*/, "") if states.length < 2
|
|
606
|
+
|
|
607
|
+
states.map do |state|
|
|
608
|
+
state_svg = state.name.to_s == selected ? original : yield(state)
|
|
609
|
+
state_svg.sub(/\A<\?xml[^>]*\?>\s*/, "")
|
|
610
|
+
.sub("<svg ", "<svg data-viewer-state=\"#{CGI.escapeHTML(state.name.to_s)}\"#{' data-active' if state.name.to_s == selected} ")
|
|
611
|
+
end.join
|
|
612
|
+
end
|
|
88
613
|
end
|
|
89
614
|
end
|
|
90
615
|
end
|