archsight 0.3.2 → 0.4.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/README.md +7 -1
- data/docs/archimate.md +21 -1
- data/docs/configuration.md +1 -0
- data/docs/diagram.md +15 -3
- data/docs/docker.md +4 -1
- data/docs/icons.md +32 -0
- data/docs/import.md +178 -55
- data/docs/index.md.erb +31 -8
- data/docs/modeling.md +772 -24
- data/docs/pages.md +44 -13
- data/docs/search.md +21 -1
- data/docs/togaf.md +5 -1
- data/lib/archsight/analysis/result.rb +8 -2
- data/lib/archsight/analysis/sandbox.rb +8 -0
- data/lib/archsight/analysis/typst_renderer.rb +246 -0
- data/lib/archsight/analysis.rb +1 -0
- data/lib/archsight/annotations/annotation.rb +30 -3
- data/lib/archsight/annotations/architecture_annotations.rb +47 -0
- data/lib/archsight/annotations/asset_annotations.rb +34 -0
- data/lib/archsight/annotations/link_annotations.rb +15 -0
- data/lib/archsight/annotations/resource_reference.rb +12 -0
- data/lib/archsight/annotations/risk_annotations.rb +21 -0
- data/lib/archsight/cli.rb +124 -23
- data/lib/archsight/database.rb +40 -0
- data/lib/archsight/diagram/cli.rb +50 -2
- data/lib/archsight/diagram/formatter.rb +112 -0
- data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
- data/lib/archsight/diagram/routing/edge_router.rb +29 -4
- data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
- data/lib/archsight/diagram/style/theme.rb +7 -2
- data/lib/archsight/documentation.rb +3 -2
- data/lib/archsight/editor/page_source.rb +13 -1
- data/lib/archsight/export/confluence/analysis_output.rb +101 -0
- data/lib/archsight/export/confluence/client.rb +13 -0
- data/lib/archsight/export/confluence/diagram_links.rb +1 -1
- data/lib/archsight/export/confluence/exporter.rb +40 -13
- data/lib/archsight/export/confluence/labels.rb +25 -0
- data/lib/archsight/export/confluence/page_properties.rb +4 -6
- data/lib/archsight/export/confluence/storage.rb +38 -2
- data/lib/archsight/helpers/embeds.rb +18 -7
- data/lib/archsight/helpers/resource_resolver.rb +25 -0
- data/lib/archsight/import/executor.rb +139 -16
- data/lib/archsight/import/handler.rb +55 -6
- data/lib/archsight/import/handler_loader.rb +64 -0
- data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
- data/lib/archsight/import/handlers/command.rb +120 -0
- data/lib/archsight/import/handlers/github.rb +4 -1
- data/lib/archsight/import/handlers/gitlab.rb +4 -1
- data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
- data/lib/archsight/import/handlers/go_grapher.rb +102 -5
- data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
- data/lib/archsight/import/handlers/grapher.rb +40 -1
- data/lib/archsight/import/handlers/repository.rb +3 -0
- data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
- data/lib/archsight/import/registry.rb +22 -1
- data/lib/archsight/import.rb +64 -1
- data/lib/archsight/linter/structure.rb +239 -0
- data/lib/archsight/linter.rb +217 -6
- data/lib/archsight/page_loader.rb +12 -1
- data/lib/archsight/query/evaluator.rb +13 -6
- data/lib/archsight/query/parser.rb +73 -6
- data/lib/archsight/query.rb +1 -1
- data/lib/archsight/references.rb +3 -3
- data/lib/archsight/renderer.rb +80 -23
- data/lib/archsight/resources/analysis.rb +3 -2
- data/lib/archsight/resources/application_component.rb +83 -8
- data/lib/archsight/resources/application_event.rb +79 -0
- data/lib/archsight/resources/application_service.rb +18 -2
- data/lib/archsight/resources/base.rb +58 -5
- data/lib/archsight/resources/business_actor.rb +14 -1
- data/lib/archsight/resources/business_contract.rb +72 -0
- data/lib/archsight/resources/business_control.rb +10 -2
- data/lib/archsight/resources/business_event.rb +79 -0
- data/lib/archsight/resources/business_process.rb +8 -1
- data/lib/archsight/resources/business_product.rb +12 -0
- data/lib/archsight/resources/business_role.rb +70 -0
- data/lib/archsight/resources/compliance_evidence.rb +8 -2
- data/lib/archsight/resources/data_object.rb +6 -1
- data/lib/archsight/resources/implementation_deliverable.rb +63 -0
- data/lib/archsight/resources/implementation_event.rb +52 -0
- data/lib/archsight/resources/implementation_gap.rb +49 -0
- data/lib/archsight/resources/implementation_plateau.rb +67 -0
- data/lib/archsight/resources/implementation_work_package.rb +84 -0
- data/lib/archsight/resources/import.rb +37 -11
- data/lib/archsight/resources/location.rb +56 -0
- data/lib/archsight/resources/motivation_assessment.rb +121 -0
- data/lib/archsight/resources/motivation_constraint.rb +7 -1
- data/lib/archsight/resources/motivation_driver.rb +50 -0
- data/lib/archsight/resources/motivation_goal.rb +13 -1
- data/lib/archsight/resources/motivation_principle.rb +75 -0
- data/lib/archsight/resources/motivation_requirement.rb +18 -2
- data/lib/archsight/resources/motivation_stakeholder.rb +21 -0
- data/lib/archsight/resources/page.rb +9 -7
- data/lib/archsight/resources/strategy_capability.rb +9 -0
- data/lib/archsight/resources/technology_artifact.rb +11 -4
- data/lib/archsight/resources/technology_communication_network.rb +89 -0
- data/lib/archsight/resources/technology_device.rb +88 -0
- data/lib/archsight/resources/technology_equipment.rb +75 -0
- data/lib/archsight/resources/technology_event.rb +80 -0
- data/lib/archsight/resources/technology_facility.rb +70 -0
- data/lib/archsight/resources/technology_node.rb +57 -1
- data/lib/archsight/resources/technology_service.rb +33 -0
- data/lib/archsight/resources/technology_system_software.rb +28 -0
- data/lib/archsight/resources.rb +12 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/analysis_helpers.rb +55 -0
- data/lib/archsight/web/api/json_helpers.rb +3 -37
- data/lib/archsight/web/api/openapi/spec.yaml +10 -8
- data/lib/archsight/web/api/page_helpers.rb +1 -8
- data/lib/archsight/web/api/routes.rb +7 -2
- data/lib/archsight/web/application.rb +38 -2
- data/lib/archsight/web/editor/routes.rb +2 -1
- data/lib/archsight/web/public/vue/ApiDocsPage-COp4onwn.js +1 -0
- data/lib/archsight/web/public/vue/DocPage-D52OwaKc.js +1 -0
- data/lib/archsight/web/public/vue/EditorPage-3aqL7yVf.js +35 -0
- data/lib/archsight/web/public/vue/ErrorPage--b9XeSYa.js +2 -0
- data/lib/archsight/web/public/vue/GraphView-qh_EtRrQ.js +1 -0
- data/lib/archsight/web/public/vue/HomePage-C2TNh2U-.js +2 -0
- data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
- data/lib/archsight/web/public/vue/InstanceRouter-uzb_OMG0.js +1 -0
- data/lib/archsight/web/public/vue/KindList-CyJuP_uq.js +1 -0
- data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
- data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
- data/lib/archsight/web/public/vue/PageView-FyygrS-b.js +1 -0
- data/lib/archsight/web/public/vue/QueryError-B4Ex_csZ.js +1 -0
- data/lib/archsight/web/public/vue/ResourceList-BY6Z1Kcb.js +2 -0
- data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
- data/lib/archsight/web/public/vue/{SearchResults-CyPUZZSC.css → SearchResults-BV6UHriJ.css} +1 -1
- data/lib/archsight/web/public/vue/SearchResults-CkFpHF8m.js +1 -0
- data/lib/archsight/web/public/vue/WikiPage-BoHNewCX.js +3 -0
- data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
- data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BvzKZE4e.js +1 -0
- data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-D-xs_Hxh.js +1 -0
- data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-nbjxG01S.js +1 -0
- data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-CW6mw2ab.js +1 -0
- data/lib/archsight/web/public/vue/index-BGlYCbi7.js +3 -0
- data/lib/archsight/web/public/vue/index-eUHmJ7K2.css +1 -0
- data/lib/archsight/web/public/vue/info-A6RAGUB7-C12sjlHe.js +1 -0
- data/lib/archsight/web/public/vue/{mermaid-BjEi5URd.js → mermaid-DjsXQ_XN.js} +35 -35
- data/lib/archsight/web/public/vue/packet-AYTQ26CC-wMwKwHmA.js +1 -0
- data/lib/archsight/web/public/vue/pie-WAS4IAKB-w1-sAU_K.js +1 -0
- data/lib/archsight/web/public/vue/radar-RG4KPBEZ-DpwqIFoy.js +1 -0
- data/lib/archsight/web/public/vue/railroad-74A4TZTK-Ce5XC24d.js +1 -0
- data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-BSQO2pds.js +1 -0
- data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-Dq1c2YtD.js +1 -0
- data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DKj6dli0.js +1 -0
- data/lib/archsight/web/public/vue/rolldown-runtime-hePW80VL.js +1 -0
- data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-BmHNTWxU.js +1 -0
- data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DsEI7sSD.js +1 -0
- data/lib/archsight/web/public/vue/{useGraphviz-DweKV7Kg.js → useGraphviz-4evusK9i.js} +13 -1
- data/lib/archsight/web/public/vue/{useHighlight-D5Gf8DLl.js → useHighlight-DPguaxfm.js} +8 -8
- data/lib/archsight/web/public/vue/usePanZoom-seQE1TPk.js +12 -0
- data/lib/archsight/web/public/vue/wardley-WFR3VGLG-CSCW83-M.js +1 -0
- data/lib/archsight/web/public/vue.html +5 -5
- metadata +72 -38
- data/lib/archsight/web/public/vue/ApiDocsPage-DoOxKjG0.js +0 -1
- data/lib/archsight/web/public/vue/DocPage-CfsC3CeQ.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-CsJA0q8n.js +0 -35
- data/lib/archsight/web/public/vue/ErrorPage-OJpJz9df.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-CBq6oFRV.js +0 -1
- data/lib/archsight/web/public/vue/HomePage-BwgMLgVC.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-DQz-SsQq.js +0 -1
- data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
- data/lib/archsight/web/public/vue/KindList-4q0K9Cll.js +0 -1
- data/lib/archsight/web/public/vue/PageView-DzeGnvoS.js +0 -1
- data/lib/archsight/web/public/vue/QueryError-kg6Yf-pW.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
- data/lib/archsight/web/public/vue/ResourceList-Df_0e0qt.js +0 -2
- data/lib/archsight/web/public/vue/SearchResults-CKgY6_wH.js +0 -1
- data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
- data/lib/archsight/web/public/vue/WikiPage-DrvCHwJ7.js +0 -13
- data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
- data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
- data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
- data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
- data/lib/archsight/web/public/vue/index-D0Q5GZRs.js +0 -3
- data/lib/archsight/web/public/vue/index-oF-iyZvk.css +0 -1
- data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
- data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
- data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
- data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
- data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
- data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
- data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
- data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
- data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
- data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
- data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Links module adds the links of a resource to other systems: `link/<name>` holds a URL, the name says which
|
|
4
|
+
# (confluence, jira, github, ...). A link has no direction; it can be where the resource comes from or where it
|
|
5
|
+
# was published to.
|
|
6
|
+
module Archsight::Annotations::Links
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.class_eval do
|
|
9
|
+
annotation "link/*",
|
|
10
|
+
description: "Links to the resource in other systems and to related documents (name and URL)",
|
|
11
|
+
sidebar: false,
|
|
12
|
+
validator: ->(value) { "'#{value}' is not an http(s) URL" unless value.match?(%r{\Ahttps?://\S+\z}) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A reference to another resource: `Name` (a resource of the same kind as the annotated one) or `Kind/Name` for
|
|
4
|
+
# another kind (names contain `:`, so `/` separates the kind). The format is checked when the annotation is
|
|
5
|
+
# validated; whether the resource exists is checked by the linter, which has the database.
|
|
6
|
+
class Archsight::Annotations::ResourceReference
|
|
7
|
+
FORMAT = %r{\A(?:[A-Za-z][A-Za-z0-9]*/)?[^\s/]+\z}
|
|
8
|
+
|
|
9
|
+
def self.valid?(value)
|
|
10
|
+
value.to_s.strip.match?(FORMAT)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Risk module adds the annotations that group security and risk resources: the paper's "risk domain" and the
|
|
4
|
+
# classification of the risk
|
|
5
|
+
module Archsight::Annotations::Risk
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.class_eval do
|
|
8
|
+
annotation "risk/domain",
|
|
9
|
+
description: "Risk domain(s) the resource belongs to (comma-separated); groups threats, risks, " \
|
|
10
|
+
"vulnerabilities, measures and assets that share a context",
|
|
11
|
+
title: "Risk domain",
|
|
12
|
+
filter: :list
|
|
13
|
+
|
|
14
|
+
annotation "risk/category",
|
|
15
|
+
description: "Classification of the risk (CAS enterprise risk classification, plus compliance and security)",
|
|
16
|
+
title: "Risk category",
|
|
17
|
+
enum: %w[hazard financial operational strategic compliance security],
|
|
18
|
+
filter: :word
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/archsight/cli.rb
CHANGED
|
@@ -52,8 +52,8 @@ module Archsight
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
54
|
def load_handlers
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
require "archsight/import"
|
|
56
|
+
Archsight::Import::HandlerLoader.load_builtin!
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def resolve_handler(path, language)
|
|
@@ -133,6 +133,12 @@ module Archsight
|
|
|
133
133
|
warnings.each { |warning| puts " #{warning}" }
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
structure = linter.structural_warnings
|
|
137
|
+
if structure.any?
|
|
138
|
+
puts "Modeling warnings (#{structure.count}):"
|
|
139
|
+
structure.each { |warning| puts " #{warning}" }
|
|
140
|
+
end
|
|
141
|
+
|
|
136
142
|
if errors.any?
|
|
137
143
|
puts "Validation Errors (#{errors.count}):"
|
|
138
144
|
errors.each { |error| display_error_with_context(error) }
|
|
@@ -172,15 +178,18 @@ module Archsight
|
|
|
172
178
|
option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "Show execution plan without running"
|
|
173
179
|
option :filter, aliases: "-f", type: :string, desc: "Filter imports by name (regex pattern)"
|
|
174
180
|
option :force, aliases: "-F", type: :boolean, default: false, desc: "Ignore cache and re-run all imports"
|
|
181
|
+
option :deps, type: :boolean, default: true,
|
|
182
|
+
desc: "With -f, also run the imports the matched ones depend on (--no-deps: only the matched ones)"
|
|
183
|
+
option :lint, type: :boolean, default: false, desc: "Validate the resources after the run (like archsight lint)"
|
|
175
184
|
def import
|
|
176
185
|
configure_resources
|
|
177
186
|
require "archsight/database"
|
|
178
|
-
require "archsight/import
|
|
187
|
+
require "archsight/import"
|
|
179
188
|
|
|
180
189
|
resources_dir = Archsight.resources_dir
|
|
181
190
|
|
|
182
|
-
#
|
|
183
|
-
|
|
191
|
+
# Built-in handlers; the executor loads the handlers of the resources repository (import/handlerPath)
|
|
192
|
+
Archsight::Import::HandlerLoader.load_builtin!
|
|
184
193
|
|
|
185
194
|
# Create database that loads from resources directory
|
|
186
195
|
# Only load the kinds that import handlers inspect at runtime (ApplicationComponent
|
|
@@ -199,7 +208,8 @@ module Archsight
|
|
|
199
208
|
resources_dir: resources_dir,
|
|
200
209
|
verbose: true,
|
|
201
210
|
filter: options[:filter],
|
|
202
|
-
force: options[:force]
|
|
211
|
+
force: options[:force],
|
|
212
|
+
deps: options[:deps]
|
|
203
213
|
)
|
|
204
214
|
executor.execution_plan
|
|
205
215
|
else
|
|
@@ -208,10 +218,12 @@ module Archsight
|
|
|
208
218
|
resources_dir: resources_dir,
|
|
209
219
|
verbose: options[:verbose],
|
|
210
220
|
filter: options[:filter],
|
|
211
|
-
force: options[:force]
|
|
221
|
+
force: options[:force],
|
|
222
|
+
deps: options[:deps]
|
|
212
223
|
)
|
|
213
224
|
executor.run!
|
|
214
225
|
puts "All imports completed successfully."
|
|
226
|
+
lint_after_import if options[:lint]
|
|
215
227
|
end
|
|
216
228
|
rescue Archsight::Import::InterruptedError
|
|
217
229
|
# Graceful shutdown already handled by executor
|
|
@@ -231,6 +243,11 @@ module Archsight
|
|
|
231
243
|
option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Verbose output"
|
|
232
244
|
option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "List analyses without running"
|
|
233
245
|
option :filter, aliases: "-f", type: :string, desc: "Filter analyses by name (regex pattern)"
|
|
246
|
+
option :format, type: :string, default: "terminal", enum: %w[terminal typst],
|
|
247
|
+
desc: "Output format: terminal rendering or Typst source (https://typst.app)"
|
|
248
|
+
option :output, aliases: "-o", type: :string, desc: "Write the Typst source to this file instead of stdout"
|
|
249
|
+
option :template, type: :string,
|
|
250
|
+
desc: "Typst file to import for the layout; it must define archsight-report and analysis-result"
|
|
234
251
|
def analyze
|
|
235
252
|
configure_resources
|
|
236
253
|
require "archsight/database"
|
|
@@ -243,7 +260,7 @@ module Archsight
|
|
|
243
260
|
return print_analysis_dry_run(analyses) if options[:dry_run]
|
|
244
261
|
|
|
245
262
|
results = execute_analyses(db, analyses)
|
|
246
|
-
print_analysis_results(results)
|
|
263
|
+
options[:format] == "typst" ? write_analysis_typst(results) : print_analysis_results(results)
|
|
247
264
|
exit 1 if results.any?(&:failed?)
|
|
248
265
|
end
|
|
249
266
|
|
|
@@ -253,7 +270,8 @@ module Archsight
|
|
|
253
270
|
end
|
|
254
271
|
|
|
255
272
|
desc "diagram INPUT...", "Render .asd diagram files to SVG"
|
|
256
|
-
long_desc "Renders each INPUT.asd to an SVG next to it (or to --output for a single input)."
|
|
273
|
+
long_desc "Renders each INPUT.asd to an SVG next to it (or to --output for a single input). " \
|
|
274
|
+
"With --format, re-indents the inputs in place instead (--check: only report the ones that are not formatted)."
|
|
257
275
|
option :output, aliases: "-o", type: :string, desc: "SVG output path (single input file only)"
|
|
258
276
|
option :watch, aliases: "-w", type: :boolean, desc: "Re-render whenever an input file changes"
|
|
259
277
|
option :relation, type: :string, desc: "Edge relations to draw: comma-separated list, or 'all' (default: dependency,implements)"
|
|
@@ -261,6 +279,8 @@ module Archsight
|
|
|
261
279
|
option :theme, type: :string, desc: "Spacing/font-size theme (default: the file's own `theme` statement)"
|
|
262
280
|
option :legend, type: :string, desc: "Legend placement (default: the file's own `legend` statement, else auto)"
|
|
263
281
|
option :profile, aliases: "-p", type: :boolean, desc: "Print stage timing stats after each render"
|
|
282
|
+
option :format, type: :boolean, desc: "Re-indent the inputs in place instead of rendering them (-o: write a single input there)"
|
|
283
|
+
option :check, type: :boolean, desc: "With --format: only list the inputs that are not formatted, exit 1 if any"
|
|
264
284
|
def diagram(*inputs)
|
|
265
285
|
require "archsight/diagram/cli"
|
|
266
286
|
|
|
@@ -275,6 +295,7 @@ module Archsight
|
|
|
275
295
|
|
|
276
296
|
status = diagram::CLI.new.run_with(
|
|
277
297
|
inputs: inputs, output: options[:output], watch: options[:watch], profile: options[:profile],
|
|
298
|
+
format: options[:format], check: options[:check],
|
|
278
299
|
style: options[:style], theme: options[:theme], legend: options[:legend],
|
|
279
300
|
relation_filter: if relation.nil?
|
|
280
301
|
diagram::Relations::DEFAULT_FILTER
|
|
@@ -288,7 +309,7 @@ module Archsight
|
|
|
288
309
|
desc "export [PAGE...]", "Export wiki pages to another system"
|
|
289
310
|
long_desc <<~DESC
|
|
290
311
|
Publishes wiki pages to the system named by --to. With no PAGE, every page that links to a target
|
|
291
|
-
page (for confluence: `confluence: <page URL
|
|
312
|
+
page (for confluence: `links: { confluence: <page URL> }` in its frontmatter) is exported. --tag TAG limits the export to pages carrying one of the given tags.
|
|
292
313
|
|
|
293
314
|
confluence: a page is only overwritten when Confluence still holds what the last export wrote. Pages
|
|
294
315
|
never exported before, or edited in Confluence since, are reported as blocked and not exported until
|
|
@@ -348,18 +369,78 @@ module Archsight
|
|
|
348
369
|
results.each { |r| puts "#{labels.fetch(r.status).ljust(width)} #{r.page}: #{r.message}" }
|
|
349
370
|
counts = results.group_by(&:status).transform_values(&:count)
|
|
350
371
|
puts counts.map { |status, count| "#{count} #{labels.fetch(status)}" }.join(", ") unless results.empty?
|
|
351
|
-
puts "Nothing to export: no page has a `confluence
|
|
372
|
+
puts "Nothing to export: no page has a `confluence` link." if results.empty?
|
|
352
373
|
end
|
|
353
374
|
|
|
354
|
-
|
|
355
|
-
|
|
375
|
+
# `archsight import --lint`: validate the whole model after the run. A resource with an error that an import
|
|
376
|
+
# generated names that import, so a dangling reference shows up with the import that caused it.
|
|
377
|
+
def lint_after_import
|
|
378
|
+
require "archsight/linter"
|
|
379
|
+
require "archsight/helpers"
|
|
380
|
+
|
|
381
|
+
db = Archsight::Database.new(Archsight.resources_dir, compute_annotations: false)
|
|
382
|
+
begin
|
|
383
|
+
db.reload!
|
|
384
|
+
rescue Archsight::ResourceError => e
|
|
385
|
+
# a relation to a resource that does not exist stops the load: name the import of the resource it is in
|
|
386
|
+
display_error_with_context("#{e}#{generated_by_note(producer_at(e.ref))}")
|
|
387
|
+
exit 1
|
|
388
|
+
end
|
|
389
|
+
errors = Archsight::Linter.new(db).validate
|
|
390
|
+
if errors.empty?
|
|
391
|
+
puts "All validations passed!"
|
|
392
|
+
return
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
puts "Validation Errors (#{errors.count}):"
|
|
396
|
+
errors.each { |error| display_error_with_context(with_generating_import(db, error)) }
|
|
397
|
+
exit 1
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Add "(generated by Import:X)" to a lint error at a location an import wrote
|
|
401
|
+
def with_generating_import(db, error)
|
|
402
|
+
path, line_no = error.match(/\A(.+?):(\d+): /)&.captures
|
|
403
|
+
return error unless path
|
|
404
|
+
|
|
405
|
+
"#{error}#{generated_by_note(producer_of(db, path, line_no.to_i))}"
|
|
356
406
|
end
|
|
357
407
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
408
|
+
# The import that a load error (a relation to a resource that does not exist) comes from, read without resolving
|
|
409
|
+
# relations
|
|
410
|
+
def producer_at(ref)
|
|
411
|
+
return nil unless ref.respond_to?(:path)
|
|
412
|
+
|
|
413
|
+
db = Archsight::Database.new(Archsight.resources_dir, compute_annotations: false, verify: false)
|
|
414
|
+
db.reload!
|
|
415
|
+
producer_of(db, ref.path, ref.line_no)
|
|
416
|
+
rescue Archsight::ResourceError
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# The import a resource at a file location comes from: its `generated/script`, else the import whose output file
|
|
421
|
+
# it is in (a command writes its documents unmarked)
|
|
422
|
+
def producer_of(db, path, line_no)
|
|
423
|
+
full_path = File.expand_path(path)
|
|
424
|
+
instance = db.instances.values.flat_map(&:values).find do |inst|
|
|
425
|
+
File.expand_path(inst.path_ref.path) == full_path && inst.path_ref.line_no == line_no
|
|
362
426
|
end
|
|
427
|
+
instance&.annotations&.[]("generated/script") || import_writing(db, full_path)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def import_writing(db, full_path)
|
|
431
|
+
db.instances_by_kind("Import").values.find do |import|
|
|
432
|
+
Archsight::Import.output_files(import).any? do |rel_path|
|
|
433
|
+
Archsight::Import.resources_path(Archsight.resources_dir, rel_path) == full_path
|
|
434
|
+
end
|
|
435
|
+
end&.name
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def generated_by_note(producer)
|
|
439
|
+
producer.to_s.empty? ? "" : " (generated by #{producer})"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def configure_resources
|
|
443
|
+
Archsight.resources_dir = options[:resources] if options[:resources]
|
|
363
444
|
end
|
|
364
445
|
|
|
365
446
|
def load_database_for_analysis
|
|
@@ -374,12 +455,12 @@ module Archsight
|
|
|
374
455
|
def filter_analyses(db)
|
|
375
456
|
analyses = db.instances_by_kind("Analysis").values
|
|
376
457
|
analyses = analyses.select { |a| Regexp.new(options[:filter], Regexp::IGNORECASE).match?(a.name) } if options[:filter]
|
|
377
|
-
analyses
|
|
458
|
+
analyses.sort_by(&:name) # same order as the dry run, independent of file layout
|
|
378
459
|
end
|
|
379
460
|
|
|
380
461
|
def print_analysis_dry_run(analyses)
|
|
381
462
|
puts "Analyses to run#{" (filter: #{options[:filter]})" if options[:filter]}:"
|
|
382
|
-
analyses.
|
|
463
|
+
analyses.each_with_index do |analysis, idx|
|
|
383
464
|
timeout = analysis.annotations["analysis/timeout"] || "30s"
|
|
384
465
|
desc = analysis.annotations["analysis/description"] || "(no description)"
|
|
385
466
|
puts " #{idx + 1}. #{analysis.name} [#{timeout}]"
|
|
@@ -392,6 +473,26 @@ module Archsight
|
|
|
392
473
|
analyses.map { |analysis| executor.execute(analysis) }
|
|
393
474
|
end
|
|
394
475
|
|
|
476
|
+
def write_analysis_typst(results)
|
|
477
|
+
typst = Archsight::Analysis::TypstRenderer.new(results, verbose: options[:verbose],
|
|
478
|
+
template: typst_template_path).render
|
|
479
|
+
if options[:output]
|
|
480
|
+
File.write(options[:output], typst)
|
|
481
|
+
warn "Wrote #{options[:output]}"
|
|
482
|
+
else
|
|
483
|
+
puts typst
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Typst resolves imports relative to the importing file, so point from the output file to the template
|
|
488
|
+
def typst_template_path
|
|
489
|
+
return unless options[:template]
|
|
490
|
+
|
|
491
|
+
require "pathname"
|
|
492
|
+
base = Pathname.new(File.expand_path(File.dirname(options[:output] || ".")))
|
|
493
|
+
Pathname.new(File.expand_path(options[:template])).relative_path_from(base).to_s
|
|
494
|
+
end
|
|
495
|
+
|
|
395
496
|
def print_analysis_results(results)
|
|
396
497
|
require "tty-markdown"
|
|
397
498
|
|
|
@@ -419,14 +520,14 @@ module Archsight
|
|
|
419
520
|
end
|
|
420
521
|
|
|
421
522
|
def build_analysis_summary_markdown(results)
|
|
422
|
-
|
|
523
|
+
completed = results.count(&:success?)
|
|
423
524
|
failed = results.count(&:failed?)
|
|
424
|
-
|
|
525
|
+
with_warnings = results.count { |result| result.success? && result.warnings? }
|
|
425
526
|
|
|
426
527
|
lines = ["---", "", "# Summary", ""]
|
|
427
|
-
lines << "- ✅ **#{
|
|
528
|
+
lines << "- ✅ **#{completed}** completed"
|
|
428
529
|
lines << "- ❌ **#{failed}** failed" if failed.positive?
|
|
429
|
-
lines << "- ⚠️ **#{
|
|
530
|
+
lines << "- ⚠️ **#{with_warnings}** with warnings" if with_warnings.positive?
|
|
430
531
|
lines.join("\n")
|
|
431
532
|
end
|
|
432
533
|
|
data/lib/archsight/database.rb
CHANGED
|
@@ -75,6 +75,7 @@ module Archsight
|
|
|
75
75
|
puts "parsing #{path}..." if @verbose
|
|
76
76
|
File.extname(path) == ".md" ? load_page(path) : load_file(path)
|
|
77
77
|
end
|
|
78
|
+
@instances.each_value { |instances| instances.each_value(&:apply_annotation_defaults!) }
|
|
78
79
|
|
|
79
80
|
verify! if @verify
|
|
80
81
|
derive_references! if @verify
|
|
@@ -93,6 +94,21 @@ module Archsight
|
|
|
93
94
|
@instances[Archsight::Resources[kind]][instance]
|
|
94
95
|
end
|
|
95
96
|
|
|
97
|
+
# The resource of a kind that has `identifier` as one of its aliases (a value of a unique annotation), when exactly
|
|
98
|
+
# one has; nil otherwise. A name always wins, so callers try instance_by_kind first.
|
|
99
|
+
def instance_by_alias(kind, identifier)
|
|
100
|
+
klass = Archsight::Resources[kind]
|
|
101
|
+
return nil unless klass
|
|
102
|
+
|
|
103
|
+
unique = klass.annotations.select(&:unique?).reject(&:pattern?)
|
|
104
|
+
return nil if unique.empty?
|
|
105
|
+
|
|
106
|
+
matches = instances_by_kind(kind).values.select do |instance|
|
|
107
|
+
unique.any? { |annotation| Array(annotation.value_for(instance)).map(&:to_s).include?(identifier) }
|
|
108
|
+
end
|
|
109
|
+
matches.first if matches.one?
|
|
110
|
+
end
|
|
111
|
+
|
|
96
112
|
# Collect unique annotation values across all instances of a kind
|
|
97
113
|
def annotation_values(kind, annotation)
|
|
98
114
|
instances = instances_by_kind(kind).values
|
|
@@ -147,6 +163,8 @@ module Archsight
|
|
|
147
163
|
obj["kind"] = current
|
|
148
164
|
end
|
|
149
165
|
|
|
166
|
+
accept_old_annotations(obj)
|
|
167
|
+
|
|
150
168
|
return unless obj["spec"].is_a?(Hash)
|
|
151
169
|
|
|
152
170
|
obj["spec"].each do |verb, keys|
|
|
@@ -161,6 +179,28 @@ module Archsight
|
|
|
161
179
|
end
|
|
162
180
|
end
|
|
163
181
|
|
|
182
|
+
# A renamed annotation key (page/confluence is now link/confluence) is moved to its new key
|
|
183
|
+
def accept_old_annotations(obj)
|
|
184
|
+
annotations = obj.dig("metadata", "annotations")
|
|
185
|
+
return unless annotations.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
Archsight::Resources::ANNOTATION_ALIASES.each do |old_key, new_key|
|
|
188
|
+
next unless annotations.key?(old_key)
|
|
189
|
+
|
|
190
|
+
deprecate("annotation '#{old_key}' is renamed to '#{new_key}'#{legacy_frontmatter_hint(old_key)}")
|
|
191
|
+
value = annotations.delete(old_key)
|
|
192
|
+
annotations[new_key] ||= value
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# In a page the annotation is a frontmatter key: `confluence:` is now `links: { confluence: ... }`
|
|
197
|
+
def legacy_frontmatter_hint(old_key)
|
|
198
|
+
return "" unless old_key.start_with?("page/")
|
|
199
|
+
|
|
200
|
+
name = old_key.delete_prefix("page/")
|
|
201
|
+
" (frontmatter `#{name}:` is now `links: { #{name}: <url> }`)"
|
|
202
|
+
end
|
|
203
|
+
|
|
164
204
|
def deprecate(message)
|
|
165
205
|
@deprecations << Deprecation.new(@current_ref, "#{message} (the old name will be removed in a future release)")
|
|
166
206
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
require_relative "."
|
|
5
|
+
require_relative "formatter"
|
|
5
6
|
|
|
6
7
|
module Archsight
|
|
7
8
|
module Diagram
|
|
@@ -18,7 +19,7 @@ module Archsight
|
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
# Runs with already-parsed options (:inputs, :output, :watch, :relation_filter,
|
|
21
|
-
# :style, :theme, :legend, :profile), e.g. from the `archsight diagram` Thor command.
|
|
22
|
+
# :style, :theme, :legend, :profile, :format, :check), e.g. from the `archsight diagram` Thor command.
|
|
22
23
|
def run_with(options)
|
|
23
24
|
if options[:inputs].empty?
|
|
24
25
|
warn "archsight-diagram: missing input file\n\n#{usage}"
|
|
@@ -30,6 +31,8 @@ module Archsight
|
|
|
30
31
|
return 1
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
return format_files(options) if options[:format]
|
|
35
|
+
|
|
33
36
|
if options[:watch]
|
|
34
37
|
watch(options)
|
|
35
38
|
0
|
|
@@ -76,6 +79,12 @@ module Archsight
|
|
|
76
79
|
opts.on("-p", "--profile", "Print stage timing stats after each render") do
|
|
77
80
|
options[:profile] = true
|
|
78
81
|
end
|
|
82
|
+
opts.on("--format", "Re-indent the inputs in place instead of rendering them (-o: write a single input there)") do
|
|
83
|
+
options[:format] = true
|
|
84
|
+
end
|
|
85
|
+
opts.on("--check", "With --format: only list the inputs that are not formatted, exit 1 if any") do
|
|
86
|
+
options[:check] = true
|
|
87
|
+
end
|
|
79
88
|
opts.on("-h", "--help", "Show this help") do
|
|
80
89
|
puts opts
|
|
81
90
|
exit 0
|
|
@@ -90,7 +99,46 @@ module Archsight
|
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
def usage
|
|
93
|
-
"Usage: archsight-diagram INPUT.asd [INPUT2.asd ...] [-o OUTPUT.svg] [--watch] [--relation=LIST] [--style=STYLE] [--theme=NAME] [--legend=MODE] [-p]"
|
|
102
|
+
"Usage: archsight-diagram INPUT.asd [INPUT2.asd ...] [-o OUTPUT.svg] [--watch] [--relation=LIST] [--style=STYLE] [--theme=NAME] [--legend=MODE] [-p] [--format [--check]]"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# `--format`: each input re-indented (see `Formatter`) and written back,
|
|
106
|
+
# or to `--output`; with `--check` nothing is written, the inputs that
|
|
107
|
+
# would change are listed and the status is 1 if there are any.
|
|
108
|
+
def format_files(options)
|
|
109
|
+
if options[:watch]
|
|
110
|
+
warn "archsight-diagram: --format cannot be combined with --watch"
|
|
111
|
+
return 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
statuses = options[:inputs].map { |input| format_once(input, options) }
|
|
115
|
+
statuses.all?(&:zero?) ? 0 : 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def format_once(input, options)
|
|
119
|
+
source = File.read(input)
|
|
120
|
+
formatted = Formatter.format(source)
|
|
121
|
+
if options[:check]
|
|
122
|
+
return 0 if formatted == source
|
|
123
|
+
|
|
124
|
+
puts "archsight-diagram: #{input} is not formatted"
|
|
125
|
+
return 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
output = options[:output] || input
|
|
129
|
+
if output == input && formatted == source
|
|
130
|
+
puts "archsight-diagram: #{input} already formatted"
|
|
131
|
+
else
|
|
132
|
+
File.write(output, formatted)
|
|
133
|
+
puts "archsight-diagram: formatted #{output}"
|
|
134
|
+
end
|
|
135
|
+
0
|
|
136
|
+
rescue Archsight::Diagram::Error => e
|
|
137
|
+
warn "archsight-diagram: #{input}: #{e.message}"
|
|
138
|
+
1
|
|
139
|
+
rescue Errno::ENOENT => e
|
|
140
|
+
warn "archsight-diagram: #{e.message}"
|
|
141
|
+
1
|
|
94
142
|
end
|
|
95
143
|
|
|
96
144
|
def output_for(input, explicit_output)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "parser"
|
|
6
|
+
|
|
7
|
+
module Archsight
|
|
8
|
+
module Diagram
|
|
9
|
+
# Tidies `.asd` source without rewriting it: every line is indented by
|
|
10
|
+
# its brace depth (`INDENT` per level, a line opening with `}` one level
|
|
11
|
+
# less), trailing whitespace is dropped, runs of blank lines become one
|
|
12
|
+
# and the file ends in exactly one newline. Comments, line breaks and
|
|
13
|
+
# one-line blocks (`component "a" { label "x"; tint "y" }`) stay as
|
|
14
|
+
# written. Works on the text, not the parse tree, because the lexer
|
|
15
|
+
# drops comments.
|
|
16
|
+
#
|
|
17
|
+
# A string spanning several lines keeps its blank lines; its
|
|
18
|
+
# continuation lines are indented one level below the line that opens
|
|
19
|
+
# it (the lexer strips that indentation, so it is not content).
|
|
20
|
+
#
|
|
21
|
+
# `format` checks that the result parses to the same diagram as the
|
|
22
|
+
# source (line numbers aside) and raises `Error` if not, so a bug here
|
|
23
|
+
# can never change a diagram.
|
|
24
|
+
module Formatter
|
|
25
|
+
INDENT = " "
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @raise [Archsight::Diagram::Error] if `source` does not parse
|
|
30
|
+
def format(source)
|
|
31
|
+
before = comparable(Parser.parse(source))
|
|
32
|
+
formatted = reindent(source)
|
|
33
|
+
raise Error, "formatting would change the diagram" unless comparable(Parser.parse(formatted)) == before
|
|
34
|
+
|
|
35
|
+
formatted
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reindent(source)
|
|
39
|
+
lines = []
|
|
40
|
+
depth = 0
|
|
41
|
+
in_string = false
|
|
42
|
+
source.each_line(chomp: true) do |raw|
|
|
43
|
+
text = raw.rstrip
|
|
44
|
+
continuation = in_string
|
|
45
|
+
closers, delta, in_string = scan(text, in_string)
|
|
46
|
+
text = text.lstrip unless continuation && text.empty?
|
|
47
|
+
level = continuation ? depth + 1 : [depth - closers, 0].max
|
|
48
|
+
lines << (text.empty? ? "" : (INDENT * level) + text) << continuation
|
|
49
|
+
depth = [depth + delta, 0].max
|
|
50
|
+
end
|
|
51
|
+
tidy(lines.each_slice(2).to_a)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `[leading closing braces, brace depth change, still inside a string
|
|
55
|
+
# at the end]` of one line, ignoring braces in strings and comments.
|
|
56
|
+
def scan(text, in_string)
|
|
57
|
+
scanner = StringScanner.new(text)
|
|
58
|
+
delta = 0
|
|
59
|
+
closers = 0
|
|
60
|
+
leading = !in_string
|
|
61
|
+
until scanner.eos?
|
|
62
|
+
if in_string
|
|
63
|
+
in_string = false if scanner.scan(/(?:[^"\\]|\\.)*"/)
|
|
64
|
+
scanner.terminate if in_string
|
|
65
|
+
leading = false
|
|
66
|
+
elsif scanner.skip(/\s+/)
|
|
67
|
+
next
|
|
68
|
+
elsif scanner.skip(/#.*/)
|
|
69
|
+
break
|
|
70
|
+
elsif scanner.skip(/"/)
|
|
71
|
+
in_string = true
|
|
72
|
+
leading = false
|
|
73
|
+
elsif scanner.skip(/\{/)
|
|
74
|
+
delta += 1
|
|
75
|
+
leading = false
|
|
76
|
+
elsif scanner.skip(/\}/)
|
|
77
|
+
delta -= 1
|
|
78
|
+
closers += 1 if leading
|
|
79
|
+
else
|
|
80
|
+
scanner.skip(/[^\s"{}#]+/)
|
|
81
|
+
leading = false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
[closers, delta, in_string]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Joins `[line, inside a string]` pairs: one blank line at most between
|
|
88
|
+
# statements (blank lines inside a string are content), none at the
|
|
89
|
+
# start or end.
|
|
90
|
+
def tidy(pairs)
|
|
91
|
+
out = []
|
|
92
|
+
pairs.each do |line, continuation|
|
|
93
|
+
next if line.empty? && !continuation && (out.empty? || out.last.empty?)
|
|
94
|
+
|
|
95
|
+
out << line
|
|
96
|
+
end
|
|
97
|
+
out.pop while out.last&.empty?
|
|
98
|
+
out.empty? ? "" : "#{out.join("\n")}\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The parse tree without line numbers, which formatting may change.
|
|
102
|
+
def comparable(node)
|
|
103
|
+
case node
|
|
104
|
+
when Array then node.map { |child| comparable(child) }
|
|
105
|
+
when Hash then node.transform_values { |value| comparable(value) }
|
|
106
|
+
when Struct then [node.class, node.to_h.except(:line).transform_values { |value| comparable(value) }]
|
|
107
|
+
else node
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -24,6 +24,14 @@ module Archsight
|
|
|
24
24
|
# resorting to a two-turn mid-jog.
|
|
25
25
|
SINGLE_TURN_RATIOS = [0.5, 0.1, 0.9].freeze
|
|
26
26
|
|
|
27
|
+
# How far a single turn's exit may also move off `a`'s center, away
|
|
28
|
+
# from `b`, as a fraction of `a`'s extent: a source whose center
|
|
29
|
+
# lies just past `b`'s side would otherwise turn right along that
|
|
30
|
+
# side, in a stub too short to read (see `EdgeRouter::MIN_END_LEG`).
|
|
31
|
+
# Only offered for such a source: anywhere else the centered exit
|
|
32
|
+
# stays the only one.
|
|
33
|
+
EXIT_SHIFT = 0.25
|
|
34
|
+
|
|
27
35
|
# A single turn entering the target off its center, remembering by
|
|
28
36
|
# how far. Entering nearer the source is always exactly that much
|
|
29
37
|
# shorter, so `EdgeRouter.score_candidates` charges it back: the
|
|
@@ -57,8 +65,10 @@ module Archsight
|
|
|
57
65
|
dx = b.x - a.x
|
|
58
66
|
dy = b.y - a.y
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
# Centered exits first, the ones shifted away from `b` after them.
|
|
69
|
+
exits = [0.0, EXIT_SHIFT]
|
|
70
|
+
horizontal = exits.product(SINGLE_TURN_RATIOS).map { |shift, ratio| single_turn_horizontal_first(a, b, dx, dy, ratio, shift) }
|
|
71
|
+
vertical = exits.product(SINGLE_TURN_RATIOS).map { |shift, ratio| single_turn_vertical_first(a, b, dx, dy, ratio, shift) }
|
|
62
72
|
# Centered entries first (dominant axis leading), off-center ones after.
|
|
63
73
|
singles = if dx.abs >= dy.abs
|
|
64
74
|
[horizontal.first, vertical.first, *horizontal.drop(1), *vertical.drop(1)]
|
|
@@ -125,15 +135,21 @@ module Archsight
|
|
|
125
135
|
# also guarantees the corner-to-entry run (at x = entry_x) clears `a`
|
|
126
136
|
# entirely; and (2) `a`'s own y sits outside `b`'s vertical span, so
|
|
127
137
|
# the exit-to-corner run (at y = a.y) clears `b` entirely.
|
|
128
|
-
|
|
138
|
+
# `exit_shift` moves the exit that fraction of `a`'s height away from
|
|
139
|
+
# `b` (see `EXIT_SHIFT`).
|
|
140
|
+
def single_turn_horizontal_first(a, b, dx, dy, ratio = 0.5, exit_shift = 0.0)
|
|
129
141
|
return nil if dx.zero?
|
|
130
142
|
|
|
131
143
|
corner_x = ratio == 0.5 ? b.x : b.left + (b.width * ratio)
|
|
132
144
|
return nil if dx.positive? ? (corner_x <= a.right) : (corner_x >= a.left)
|
|
133
|
-
return nil unless a.y < b.top || a.y > b.bottom
|
|
134
145
|
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
return nil unless exit_shift.zero? || short_run?(a.y, b.top, b.bottom)
|
|
147
|
+
|
|
148
|
+
exit_y = a.y - ((dy <=> 0) * a.height * exit_shift)
|
|
149
|
+
return nil unless exit_y < b.top || exit_y > b.bottom
|
|
150
|
+
|
|
151
|
+
exit_point = [dx.positive? ? a.right : a.left, exit_y]
|
|
152
|
+
corner = [corner_x, exit_y]
|
|
137
153
|
entry_point = [corner_x, dy.positive? ? b.top : b.bottom]
|
|
138
154
|
off_center(ratio, corner_x - b.x, [exit_point, corner, entry_point])
|
|
139
155
|
end
|
|
@@ -141,19 +157,31 @@ module Archsight
|
|
|
141
157
|
# The transpose of `single_turn_horizontal_first`: exit `a` from
|
|
142
158
|
# whichever top/bottom edge faces `b`, corner at `(a.x, b.y)`, then
|
|
143
159
|
# straight into `b`'s facing left/right edge.
|
|
144
|
-
def single_turn_vertical_first(a, b, dx, dy, ratio = 0.5)
|
|
160
|
+
def single_turn_vertical_first(a, b, dx, dy, ratio = 0.5, exit_shift = 0.0)
|
|
145
161
|
return nil if dy.zero?
|
|
146
162
|
|
|
147
163
|
corner_y = ratio == 0.5 ? b.y : b.top + (b.height * ratio)
|
|
148
164
|
return nil if dy.positive? ? (corner_y <= a.bottom) : (corner_y >= a.top)
|
|
149
|
-
return nil unless a.x < b.left || a.x > b.right
|
|
150
165
|
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
return nil unless exit_shift.zero? || short_run?(a.x, b.left, b.right)
|
|
167
|
+
|
|
168
|
+
exit_x = a.x - ((dx <=> 0) * a.width * exit_shift)
|
|
169
|
+
return nil unless exit_x < b.left || exit_x > b.right
|
|
170
|
+
|
|
171
|
+
exit_point = [exit_x, dy.positive? ? a.bottom : a.top]
|
|
172
|
+
corner = [exit_x, corner_y]
|
|
153
173
|
entry_point = [dx.positive? ? b.left : b.right, corner_y]
|
|
154
174
|
off_center(ratio, corner_y - b.y, [exit_point, corner, entry_point])
|
|
155
175
|
end
|
|
156
176
|
|
|
177
|
+
# Whether a centered exit at `coord` lies just outside `b`'s span
|
|
178
|
+
# `low..high`, so its run into `b` would be shorter than
|
|
179
|
+
# `EdgeRouter::MIN_END_LEG`.
|
|
180
|
+
def short_run?(coord, low, high)
|
|
181
|
+
gap = coord < low ? low - coord : coord - high
|
|
182
|
+
gap.positive? && gap < EdgeRouter::MIN_END_LEG
|
|
183
|
+
end
|
|
184
|
+
|
|
157
185
|
def off_center(ratio, offset, points)
|
|
158
186
|
ratio == SINGLE_TURN_RATIOS.first ? points : OffCenterPath.new(points, offset.abs)
|
|
159
187
|
end
|