conceptql 0.6.0 → 0.7.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 +176 -0
- data/README.md +4 -2
- data/assets/diagram/diagram.css +331 -0
- data/assets/diagram/fonts/OFL.txt +93 -0
- data/assets/diagram/fonts/Roboto-400.ttf +0 -0
- data/assets/diagram/fonts/Roboto-700.ttf +0 -0
- data/assets/diagram/fonts/Roboto-Italic-400.ttf +0 -0
- data/assets/diagram/fonts/Roboto-Italic-Latin.woff2 +0 -0
- data/assets/diagram/fonts/Roboto-Normal-Latin.woff2 +0 -0
- data/assets/diagram/operators.svg +224 -0
- data/config/vocabularies.csv +85 -85
- data/docs/spec/README.md +2624 -0
- data/docs/spec/examples/after-operator.json +562 -0
- data/docs/spec/examples/any-overlap-operator.json +106 -0
- data/docs/spec/examples/before-operator.json +437 -0
- data/docs/spec/examples/co-reported-operator.json +168 -0
- data/docs/spec/examples/contains-operator.json +97 -0
- data/docs/spec/examples/date-range-operator.json +275 -0
- data/docs/spec/examples/death-operator.json +103 -0
- data/docs/spec/examples/during-operator.json +178 -0
- data/docs/spec/examples/episode-operator.json +80 -0
- data/docs/spec/examples/equal-operator.json +79 -0
- data/docs/spec/examples/ethnicity-operator.json +86 -0
- data/docs/spec/examples/except-operator.json +536 -0
- data/docs/spec/examples/filter-operator.json +218 -0
- data/docs/spec/examples/first-operator.json +148 -0
- data/docs/spec/examples/gender-operator.json +257 -0
- data/docs/spec/examples/hospice-operator.json +40 -0
- data/docs/spec/examples/hospitalization-operator.json +131 -0
- data/docs/spec/examples/information-periods-operator.json +131 -0
- data/docs/spec/examples/last-operator.json +148 -0
- data/docs/spec/examples/match-operator.json +186 -0
- data/docs/spec/examples/nth-occurrence-operator.json +300 -0
- data/docs/spec/examples/numeric-filter-operator.json +77 -0
- data/docs/spec/examples/one-in-two-out-operator.json +158 -0
- data/docs/spec/examples/person-filter-operator.json +919 -0
- data/docs/spec/examples/person-operator.json +274 -0
- data/docs/spec/examples/place-of-service-filter-operator.json +57 -0
- data/docs/spec/examples/provenance-operator.json +148 -0
- data/docs/spec/examples/provider-filter-operator.json +77 -0
- data/docs/spec/examples/race-operator.json +131 -0
- data/docs/spec/examples/recall-operator.json +289 -0
- data/docs/spec/examples/snf-operator.json +40 -0
- data/docs/spec/examples/time-window-operator.json +736 -0
- data/docs/spec/examples/trim-date-end-operator.json +201 -0
- data/docs/spec/examples/trim-date-start-operator.json +201 -0
- data/docs/spec/examples/union-operator.json +547 -0
- data/docs/spec/examples/vocabulary-operator.json +232 -0
- data/lib/conceptql/behaviors/metadatable.rb +68 -2
- data/lib/conceptql/behaviors/provenanceable.rb +4 -1
- data/lib/conceptql/behaviors/utilizable.rb +3 -0
- data/lib/conceptql/code_case_mismatch_error.rb +50 -0
- data/lib/conceptql/data_model/gdm.rb +23 -2
- data/lib/conceptql/diagram_renderer/app.rb +59 -0
- data/lib/conceptql/diagram_renderer/configuration.rb +87 -0
- data/lib/conceptql/diagram_renderer/diagram_data.rb +80 -0
- data/lib/conceptql/diagram_renderer/drawing.rb +255 -0
- data/lib/conceptql/diagram_renderer/geometry.rb +380 -0
- data/lib/conceptql/diagram_renderer/input_error.rb +11 -0
- data/lib/conceptql/diagram_renderer/js_format.rb +112 -0
- data/lib/conceptql/diagram_renderer/resvg.rb +120 -0
- data/lib/conceptql/diagram_renderer/sprite.rb +73 -0
- data/lib/conceptql/diagram_renderer/static.rb +70 -0
- data/lib/conceptql/diagram_renderer/svg.rb +73 -0
- data/lib/conceptql/diagram_renderer/tree_layout.rb +287 -0
- data/lib/conceptql/diagram_renderer.rb +82 -0
- data/lib/conceptql/lexicon/lexicon_strategy.rb +16 -0
- data/lib/conceptql/lexicon.rb +63 -11
- data/lib/conceptql/nodifier.rb +5 -3
- data/lib/conceptql/operators/after.rb +15 -1
- data/lib/conceptql/operators/any_overlap.rb +6 -1
- data/lib/conceptql/operators/before.rb +11 -1
- data/lib/conceptql/operators/binary_operator_operator.rb +12 -2
- data/lib/conceptql/operators/co_reported.rb +9 -4
- data/lib/conceptql/operators/contains.rb +6 -1
- data/lib/conceptql/operators/count.rb +2 -0
- data/lib/conceptql/operators/date_range.rb +15 -3
- data/lib/conceptql/operators/death.rb +5 -1
- data/lib/conceptql/operators/dedup.rb +2 -0
- data/lib/conceptql/operators/during.rb +6 -1
- data/lib/conceptql/operators/episode.rb +13 -2
- data/lib/conceptql/operators/equal.rb +11 -1
- data/lib/conceptql/operators/ethnicity.rb +19 -7
- data/lib/conceptql/operators/except.rb +10 -1
- data/lib/conceptql/operators/filter.rb +6 -1
- data/lib/conceptql/operators/first.rb +19 -9
- data/lib/conceptql/operators/from.rb +3 -0
- data/lib/conceptql/operators/gender.rb +10 -2
- data/lib/conceptql/operators/hospice.rb +7 -1
- data/lib/conceptql/operators/hospitalization.rb +6 -1
- data/lib/conceptql/operators/information_periods.rb +26 -7
- data/lib/conceptql/operators/invalid.rb +4 -0
- data/lib/conceptql/operators/last.rb +19 -9
- data/lib/conceptql/operators/match.rb +6 -1
- data/lib/conceptql/operators/multiple_vocabularies.rb +17 -3
- data/lib/conceptql/operators/numeric.rb +4 -0
- data/lib/conceptql/operators/numeric_filter.rb +13 -3
- data/lib/conceptql/operators/occurrence.rb +33 -12
- data/lib/conceptql/operators/one_in_two_out.rb +22 -5
- data/lib/conceptql/operators/operator.rb +8 -4
- data/lib/conceptql/operators/person.rb +5 -1
- data/lib/conceptql/operators/person_filter.rb +14 -1
- data/lib/conceptql/operators/place_of_service_filter.rb +14 -3
- data/lib/conceptql/operators/provenance.rb +17 -8
- data/lib/conceptql/operators/provider_filter.rb +18 -6
- data/lib/conceptql/operators/race.rb +14 -9
- data/lib/conceptql/operators/read.rb +11 -1
- data/lib/conceptql/operators/recall.rb +34 -2
- data/lib/conceptql/operators/snf.rb +7 -1
- data/lib/conceptql/operators/sum.rb +4 -0
- data/lib/conceptql/operators/temporal_operator.rb +4 -2
- data/lib/conceptql/operators/time_window.rb +27 -4
- data/lib/conceptql/operators/trim_date.rb +6 -14
- data/lib/conceptql/operators/trim_date_end.rb +9 -3
- data/lib/conceptql/operators/trim_date_start.rb +9 -3
- data/lib/conceptql/operators/union.rb +13 -1
- data/lib/conceptql/operators/vocabulary.rb +80 -6
- data/lib/conceptql/paths.rb +9 -1
- data/lib/conceptql/version.rb +1 -1
- data/lib/conceptql/vocabularies/drg_systems.rb +3 -2
- data/lib/conceptql/vocabularies/entry.rb +14 -11
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +2 -1
- data/lib/conceptql.rb +3 -0
- metadata +75 -2
- data/doc/spec.md +0 -2091
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConceptQL
|
|
4
|
+
module DiagramRenderer
|
|
5
|
+
# The JavaScript rules that the ported renderer code depends on: how a value
|
|
6
|
+
# becomes a string (String(value), template literals, Array#join), which
|
|
7
|
+
# values are falsy, and the order of Object.entries. The JavaScript renderer
|
|
8
|
+
# builds path strings and labels with these rules, so the Ruby port must use
|
|
9
|
+
# them to produce the same strings.
|
|
10
|
+
module JsFormat
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Number::toString (ECMAScript 6.1.6.1.20) for a finite or non-finite
|
|
14
|
+
# number: 100.0 is "100", 1.0e-7 is "1e-7", -0.0 is "0".
|
|
15
|
+
def number(value)
|
|
16
|
+
return value.to_s if value.is_a?(Integer)
|
|
17
|
+
|
|
18
|
+
value = Float(value)
|
|
19
|
+
return "NaN" if value.nan?
|
|
20
|
+
return value.positive? ? "Infinity" : "-Infinity" if value.infinite?
|
|
21
|
+
return "0" if value.zero?
|
|
22
|
+
return "-#{number(-value)}" if value.negative?
|
|
23
|
+
|
|
24
|
+
digits, n = shortest_digits(value)
|
|
25
|
+
k = digits.length
|
|
26
|
+
if k <= n && n <= 21
|
|
27
|
+
digits + ("0" * (n - k))
|
|
28
|
+
elsif n.positive? && n <= 21
|
|
29
|
+
"#{digits[0, n]}.#{digits[n..]}"
|
|
30
|
+
elsif n > -6 && n <= 0
|
|
31
|
+
"0.#{'0' * -n}#{digits}"
|
|
32
|
+
else
|
|
33
|
+
exponent = n - 1
|
|
34
|
+
sign = exponent.negative? ? "-" : "+"
|
|
35
|
+
mantissa = k == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
|
|
36
|
+
"#{mantissa}e#{sign}#{exponent.abs}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# String(value) for a JSON value.
|
|
41
|
+
def string(value)
|
|
42
|
+
case value
|
|
43
|
+
when nil then "null"
|
|
44
|
+
when String then value
|
|
45
|
+
when Numeric then number(value)
|
|
46
|
+
when Array then join(value, ",")
|
|
47
|
+
when Hash then "[object Object]"
|
|
48
|
+
else value.to_s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Array.prototype.join: null elements become "".
|
|
53
|
+
def join(array, separator)
|
|
54
|
+
array.map { _1.nil? ? "" : string(_1) }.join(separator)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# JavaScript truthiness for a JSON value.
|
|
58
|
+
def truthy?(value)
|
|
59
|
+
case value
|
|
60
|
+
when nil, false then false
|
|
61
|
+
when String then !value.empty?
|
|
62
|
+
when Float then !(value.zero? || value.nan?)
|
|
63
|
+
when Integer then !value.zero?
|
|
64
|
+
else true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The length of a value as JavaScript reads `value.length`, for the
|
|
69
|
+
# comparisons in geometry.js: a string's or an array's length, otherwise
|
|
70
|
+
# NaN (JavaScript's undefined, which compares false with every number).
|
|
71
|
+
#
|
|
72
|
+
# A string's length counts characters (code points). JavaScript counts
|
|
73
|
+
# UTF-16 code units, so a character outside the Basic Multilingual Plane
|
|
74
|
+
# (an emoji) counts 1 here and 2 in JavaScript.
|
|
75
|
+
def length(value)
|
|
76
|
+
value.is_a?(String) || value.is_a?(Array) ? value.length : Float::NAN
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Object.entries order for a JSON object: integer-like keys first, in
|
|
80
|
+
# numeric order, then the other keys in insertion order.
|
|
81
|
+
def entries(hash)
|
|
82
|
+
index, other = hash.partition { |key, _| array_index?(key) }
|
|
83
|
+
index.sort_by { |key, _| key.to_i } + other
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# String.prototype.trimEnd: the JavaScript WhiteSpace and LineTerminator
|
|
87
|
+
# characters.
|
|
88
|
+
TRAILING_WHITESPACE = /[\t\n\v\f\r -
]+\z/
|
|
89
|
+
|
|
90
|
+
def trim_end(text) = text.sub(TRAILING_WHITESPACE, "")
|
|
91
|
+
|
|
92
|
+
# The shortest decimal digits that round-trip to `value` (positive,
|
|
93
|
+
# finite), and the exponent n of ECMAScript's definition: value is
|
|
94
|
+
# 0.<digits> x 10^n. Ruby's Float#to_s prints the same shortest digits.
|
|
95
|
+
def shortest_digits(value)
|
|
96
|
+
mantissa, exponent = value.to_s.split("e")
|
|
97
|
+
whole, fraction = mantissa.split(".")
|
|
98
|
+
digits = whole + fraction.to_s
|
|
99
|
+
n = whole.length + exponent.to_i
|
|
100
|
+
stripped = digits.sub(/\A0+/, "")
|
|
101
|
+
n -= digits.length - stripped.length
|
|
102
|
+
[ stripped.sub(/0+\z/, ""), n ]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def array_index?(key)
|
|
106
|
+
key.is_a?(String) && key.match?(/\A(?:0|[1-9]\d*)\z/) && key.to_i < 4_294_967_295
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private_class_method :shortest_digits, :array_index?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module ConceptQL
|
|
7
|
+
module DiagramRenderer
|
|
8
|
+
# The resvg command-line program (https://github.com/linebender/resvg), run
|
|
9
|
+
# as a subprocess: the bounding box of a drawing and PNG output.
|
|
10
|
+
# mise.toml pins the release (VERSION) and says why it is that release.
|
|
11
|
+
# resvg is also the engine of the JavaScript static exporter
|
|
12
|
+
# (@resvg/resvg-js in frontend/lib/conceptql-diagram/cli). Both use the same
|
|
13
|
+
# fonts: the three Roboto files, no system fonts, default family Roboto
|
|
14
|
+
# (resvgOptions() in cli/src/render-static.js).
|
|
15
|
+
#
|
|
16
|
+
# resvg = ConceptQL::DiagramRenderer::Resvg.new(fonts_dir: "assets/diagram/fonts")
|
|
17
|
+
# resvg.bbox(svg) # => [x, y, width, height]
|
|
18
|
+
# resvg.png(svg) # => PNG bytes
|
|
19
|
+
class Resvg
|
|
20
|
+
VERSION = "0.33.0"
|
|
21
|
+
FONT_FILES = %w[Roboto-400.ttf Roboto-700.ttf Roboto-Italic-400.ttf].freeze
|
|
22
|
+
DEFAULT_FONT_FAMILY = "Roboto"
|
|
23
|
+
# The id of the group that bbox wraps around the drawing.
|
|
24
|
+
BBOX_ID = "conceptql-diagram-bbox"
|
|
25
|
+
# The root element's start tag. A quoted attribute value may hold ">".
|
|
26
|
+
ROOT_START = /<svg\b(?:[^>"']|"[^"]*"|'[^']*')*>/
|
|
27
|
+
|
|
28
|
+
# A failed run: the program is missing, exits non-zero, or prints no box.
|
|
29
|
+
class Error < StandardError
|
|
30
|
+
attr_reader :command, :status, :stderr
|
|
31
|
+
|
|
32
|
+
def initialize(command:, status: nil, stderr: "", reason: nil)
|
|
33
|
+
@command = command
|
|
34
|
+
@status = status
|
|
35
|
+
@stderr = stderr
|
|
36
|
+
exit_text = status ? "exit status #{status}" : "no exit status"
|
|
37
|
+
super([ "resvg failed: #{reason || exit_text}", "command: #{command.join(' ')}", "stderr: #{stderr}" ].join("\n"))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :program, :fonts_dir
|
|
42
|
+
|
|
43
|
+
# fonts_dir holds FONT_FILES. program is the resvg executable (a name on
|
|
44
|
+
# PATH or a path).
|
|
45
|
+
def initialize(fonts_dir:, program: "resvg")
|
|
46
|
+
@fonts_dir = fonts_dir.to_s
|
|
47
|
+
@program = program.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The version that `resvg --version` prints, e.g. "0.33.0".
|
|
51
|
+
def version
|
|
52
|
+
run([ "--version" ]).strip
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The bounding box of the whole drawing, in user units, as
|
|
56
|
+
# [x, y, width, height]: what resvg-js getBBox() returns for the same SVG.
|
|
57
|
+
#
|
|
58
|
+
# The CLI has no "box of everything" mode. --query-all prints
|
|
59
|
+
# "id,x,y,width,height" for each element with an id, with text converted
|
|
60
|
+
# to paths and the stroke included, as getBBox() does. The root has no id,
|
|
61
|
+
# so bbox wraps the root's content in one group with BBOX_ID and reads
|
|
62
|
+
# that group's line. In resvg 0.33 the box is in the coordinates of the
|
|
63
|
+
# root's content, so the viewBox and width/height do not change it.
|
|
64
|
+
# --query-all rounds to 3 decimal places. An empty drawing has no box and
|
|
65
|
+
# raises Error.
|
|
66
|
+
def bbox(svg)
|
|
67
|
+
start = svg.match(ROOT_START) or raise ArgumentError, "no <svg> start tag in the input"
|
|
68
|
+
close = svg.rindex("</svg>") or raise ArgumentError, "no </svg> end tag in the input"
|
|
69
|
+
wrapped = "#{svg[0...start.end(0)]}<g id=\"#{BBOX_ID}\">#{svg[start.end(0)...close]}</g>#{svg[close..]}"
|
|
70
|
+
with_input(wrapped) do |input|
|
|
71
|
+
command = [ "--query-all", *font_arguments, input ]
|
|
72
|
+
line = run(command).lines.find { |l| l.start_with?("#{BBOX_ID},") }
|
|
73
|
+
raise Error.new(command: full(command), status: 0, reason: "no bounding box for ##{BBOX_ID}; the drawing may be empty") unless line
|
|
74
|
+
|
|
75
|
+
line.strip.split(",").drop(1).map { |n| Float(n) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The SVG rasterized to PNG bytes, at the size of its width and height.
|
|
80
|
+
def png(svg)
|
|
81
|
+
with_input(svg) do |input|
|
|
82
|
+
output = File.join(File.dirname(input), "out.png")
|
|
83
|
+
run([ *font_arguments, input, output ])
|
|
84
|
+
File.binread(output)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def font_arguments
|
|
91
|
+
FONT_FILES.flat_map { |f| [ "--use-font-file", File.join(fonts_dir, f) ] } +
|
|
92
|
+
[ "--skip-system-fonts", "--font-family", DEFAULT_FONT_FAMILY ]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def full(arguments) = [ program, *arguments ]
|
|
96
|
+
|
|
97
|
+
# The SVG goes to the program as a file in a new temporary directory, not
|
|
98
|
+
# on stdin: with stdin input, resvg prints a warning to stdout, which
|
|
99
|
+
# would corrupt PNG output on stdout. The directory is also the
|
|
100
|
+
# program's --resources-dir default, so a relative href finds nothing.
|
|
101
|
+
def with_input(svg)
|
|
102
|
+
Dir.mktmpdir("conceptql-diagram-resvg") do |dir|
|
|
103
|
+
input = File.join(dir, "in.svg")
|
|
104
|
+
File.write(input, svg)
|
|
105
|
+
yield input
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run(arguments)
|
|
110
|
+
command = full(arguments)
|
|
111
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
112
|
+
raise Error.new(command:, status: status.exitstatus, stderr:) unless status.success?
|
|
113
|
+
|
|
114
|
+
stdout
|
|
115
|
+
rescue SystemCallError => e
|
|
116
|
+
raise Error.new(command:, stderr: "", reason: "cannot run #{program}: #{e.message}")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConceptQL
|
|
4
|
+
module DiagramRenderer
|
|
5
|
+
# The operator icon sprite: markup that holds <symbol id="operator-..">
|
|
6
|
+
# elements, which the drawing's <use> elements refer to. The gem's copy,
|
|
7
|
+
# assets/diagram/operators.svg, is the content of the hidden
|
|
8
|
+
# <div class="operators"> of frontend/index.html. The markup may also be a
|
|
9
|
+
# whole HTML page: only the elements with an id matter.
|
|
10
|
+
#
|
|
11
|
+
# The sprite is read with a small tag scanner, not an XML parser (the
|
|
12
|
+
# library uses the standard library only). It expects well-formed markup
|
|
13
|
+
# with quoted attribute values, as the sprite is. Each element with an id
|
|
14
|
+
# keeps its markup exactly as written, for copying into an SVG.
|
|
15
|
+
class Sprite
|
|
16
|
+
TAG = %r{
|
|
17
|
+
<!--.*?-->
|
|
18
|
+
| <!\[CDATA\[.*?\]\]>
|
|
19
|
+
| <[!?][^>]*>
|
|
20
|
+
| </(?<close>[^\s>]+)\s*>
|
|
21
|
+
| <(?<open>[^\s/>]+)(?<attributes>(?:[^>"']|"[^"]*"|'[^']*')*?)(?<empty>/?)>
|
|
22
|
+
}mx
|
|
23
|
+
ATTRIBUTE = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
|
|
24
|
+
|
|
25
|
+
# One element with an id: where its start tag is, its markup, and the ids
|
|
26
|
+
# its descendants refer to (Svg.references of each, in document order).
|
|
27
|
+
Entry = Data.define(:at, :markup, :references)
|
|
28
|
+
|
|
29
|
+
def initialize(markup)
|
|
30
|
+
@entries = {}
|
|
31
|
+
scan(markup)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def key?(id) = @entries.key?(id)
|
|
35
|
+
|
|
36
|
+
# The markup of the element with this id.
|
|
37
|
+
def markup(id) = @entries.fetch(id).markup
|
|
38
|
+
|
|
39
|
+
# The ids that the element's descendants refer to.
|
|
40
|
+
def references(id) = @entries.fetch(id).references
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# As querySelectorAll("[id]") into a Map: when two elements share an id,
|
|
45
|
+
# the later one (in document order) wins.
|
|
46
|
+
def scan(markup)
|
|
47
|
+
open = []
|
|
48
|
+
tags = []
|
|
49
|
+
markup.scan(TAG) do
|
|
50
|
+
match = Regexp.last_match
|
|
51
|
+
if match[:open]
|
|
52
|
+
attributes = match[:attributes].scan(ATTRIBUTE).to_h { |name, double, single| [ name, double || single ] }
|
|
53
|
+
tag = { at: match.begin(0), id: attributes["id"], references: Svg.references(attributes) }
|
|
54
|
+
tags << tag
|
|
55
|
+
match[:empty].empty? ? open.push([ match[:open], tag ]) : record(markup, tag, tags, match.end(0))
|
|
56
|
+
elsif match[:close]
|
|
57
|
+
index = open.rindex { |name, _| name == match[:close] } or next
|
|
58
|
+
_, tag = open.slice!(index..).first
|
|
59
|
+
record(markup, tag, tags, match.end(0))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def record(markup, tag, tags, finish)
|
|
65
|
+
return unless tag[:id]
|
|
66
|
+
return if @entries[tag[:id]] && @entries[tag[:id]].at > tag[:at]
|
|
67
|
+
|
|
68
|
+
inner = tags.select { |t| t[:at] > tag[:at] && t[:at] < finish }.flat_map { _1[:references] }
|
|
69
|
+
@entries[tag[:id]] = Entry.new(at: tag[:at], markup: markup[tag[:at]...finish], references: inner)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConceptQL
|
|
4
|
+
module DiagramRenderer
|
|
5
|
+
# A standalone static SVG of a diagram: renderStatic in
|
|
6
|
+
# frontend/lib/conceptql-diagram/cli/src/render-static.js. The Drawing,
|
|
7
|
+
# then makeStandalone (inline the sprite symbols the drawing uses, add the
|
|
8
|
+
# <style>) and fitToContent (a viewBox around the drawing, measured by
|
|
9
|
+
# resvg, and a background rect).
|
|
10
|
+
#
|
|
11
|
+
# ConceptQL::DiagramRenderer::Static.new(configuration).render(input) # => SVG text
|
|
12
|
+
class Static
|
|
13
|
+
PADDING = 24
|
|
14
|
+
BACKGROUND = "#f4f2f3"
|
|
15
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
16
|
+
XLINK_NS = "http://www.w3.org/1999/xlink"
|
|
17
|
+
# The canvas that fitToContent measures on: larger than any diagram.
|
|
18
|
+
MEASURE = [ -5000, -5000, 10_000, 10_000 ].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(configuration)
|
|
21
|
+
@configuration = configuration
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render(input, show_counts: false, show_options: false)
|
|
25
|
+
drawing = Drawing.new(DiagramData.from(input), show_counts:, show_options:)
|
|
26
|
+
algorithm = drawing.algorithm
|
|
27
|
+
style = Svg.element("style", {}, [ @configuration.standalone_style_text ])
|
|
28
|
+
defs = Svg.element("defs", {}, drawing.gradients + symbols(algorithm))
|
|
29
|
+
|
|
30
|
+
x, y, width, height = @configuration.resvg.bbox(root(MEASURE, [ style, defs, algorithm ]).to_s)
|
|
31
|
+
raise "the diagram rendered nothing measurable" unless width.positive? && height.positive?
|
|
32
|
+
|
|
33
|
+
view_box = [ (x - PADDING).floor, (y - PADDING).floor, (width + (2 * PADDING)).ceil, (height + (2 * PADDING)).ceil ]
|
|
34
|
+
background = Svg.element("rect", { "x" => view_box[0], "y" => view_box[1], "width" => view_box[2], "height" => view_box[3], "fill" => BACKGROUND })
|
|
35
|
+
root(view_box, [ style, defs, background, algorithm ]).to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def root(view_box, children)
|
|
41
|
+
Svg.element("svg", {
|
|
42
|
+
"xmlns" => SVG_NS,
|
|
43
|
+
"xmlns:xlink" => XLINK_NS,
|
|
44
|
+
"class" => "vis is-new",
|
|
45
|
+
"viewBox" => view_box.join(" "),
|
|
46
|
+
"width" => view_box[2],
|
|
47
|
+
"height" => view_box[3]
|
|
48
|
+
}, children)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The sprite elements the drawing refers to, and those they refer to, in
|
|
52
|
+
# the order makeStandalone finds them: document order, each one's own
|
|
53
|
+
# references right after it.
|
|
54
|
+
def symbols(algorithm)
|
|
55
|
+
sprite = @configuration.sprite
|
|
56
|
+
needed = []
|
|
57
|
+
visit = lambda do |refs|
|
|
58
|
+
refs.each do |id|
|
|
59
|
+
next if needed.include?(id) || !sprite.key?(id)
|
|
60
|
+
|
|
61
|
+
needed << id
|
|
62
|
+
visit.call(sprite.references(id))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
visit.call(algorithm.descendants.flat_map { Svg.references(_1.attributes) })
|
|
66
|
+
needed.map { Svg::Raw.new(sprite.markup(_1)) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConceptQL
|
|
4
|
+
module DiagramRenderer
|
|
5
|
+
# SVG markup built with plain strings: an element tree, its serialization
|
|
6
|
+
# with XML escaping, and the references (#id) that its elements make.
|
|
7
|
+
#
|
|
8
|
+
# Attribute values and text are written as the DOM writes the values that
|
|
9
|
+
# renderer.js sets: a number as JavaScript's Number toString writes it
|
|
10
|
+
# (JsFormat), and a nil attribute is left out (d3's attr(name, null)
|
|
11
|
+
# removes the attribute). Every attribute value and text is escaped, because
|
|
12
|
+
# operator names, values and options come from users.
|
|
13
|
+
module Svg
|
|
14
|
+
# An element: a name, attributes in the order they are written, and
|
|
15
|
+
# children (Element, Raw or String text).
|
|
16
|
+
Element = Data.define(:name, :attributes, :children) do
|
|
17
|
+
def initialize(name:, attributes: {}, children: [])
|
|
18
|
+
super(name:, attributes: attributes.reject { |_, value| value.nil? }.transform_values { JsFormat.string(_1) }, children:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
attrs = attributes.map { |key, value| %( #{key}="#{Svg.escape_attribute(value)}") }.join
|
|
23
|
+
return "<#{name}#{attrs}/>" if children.empty?
|
|
24
|
+
|
|
25
|
+
"<#{name}#{attrs}>#{children.map { _1.is_a?(String) ? Svg.escape_text(_1) : _1.to_s }.join}</#{name}>"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The element's descendants in document order (querySelectorAll("*")),
|
|
29
|
+
# without the element itself.
|
|
30
|
+
def descendants
|
|
31
|
+
children.grep(Element).flat_map { [ _1, *_1.descendants ] }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Markup copied as it is, such as an icon <symbol> from the sprite.
|
|
36
|
+
Raw = Data.define(:markup) do
|
|
37
|
+
def to_s = markup
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The attributes that makeStandalone (cli/src/render-static.js) reads a
|
|
41
|
+
# url(#id) reference from, in the order it reads them.
|
|
42
|
+
URL_ATTRIBUTES = %w[fill stroke filter clip-path mask].freeze
|
|
43
|
+
URL_REFERENCE = /url\(#([^)]+)\)/
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def element(name, attributes = {}, children = []) = Element.new(name:, attributes:, children:)
|
|
48
|
+
|
|
49
|
+
# The ids that one element's attributes refer to, in the order
|
|
50
|
+
# makeStandalone finds them: the href (xlink:href, else href), then a
|
|
51
|
+
# url(#id) in each of URL_ATTRIBUTES. attributes is a hash of qualified
|
|
52
|
+
# names to values.
|
|
53
|
+
def references(attributes)
|
|
54
|
+
href = [ attributes["xlink:href"], attributes["href"] ].find { _1 && !_1.empty? }
|
|
55
|
+
refs = href&.start_with?("#") ? [ href[1..] ] : []
|
|
56
|
+
refs + URL_ATTRIBUTES.filter_map { attributes[_1]&.match(URL_REFERENCE)&.[](1) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An attribute value as XMLSerializer escapes it. The tab, newline and
|
|
60
|
+
# carriage return are character references, so a parser reads them back
|
|
61
|
+
# unchanged (it would read a raw one as a space).
|
|
62
|
+
def escape_attribute(value)
|
|
63
|
+
value.gsub(/[&<>"\t\n\r]/, "&" => "&", "<" => "<", ">" => ">", '"' => """, "\t" => "	", "\n" => "
", "\r" => "
")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Text as XMLSerializer escapes it. A carriage return is a character
|
|
67
|
+
# reference, so a parser does not turn it into a newline.
|
|
68
|
+
def escape_text(text)
|
|
69
|
+
text.gsub(/[&<>\r]/, "&" => "&", "<" => "<", ">" => ">", "\r" => "
")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|