asobi 0.1.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.
data/lib/asobi/many.rb ADDED
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # Several pictures that are, together, one picture.
5
+ #
6
+ # It is Enumerable, so everything you know about collections applies
7
+ # (each, select, each_slice, zip, count ...), and it is a picture, so
8
+ # every word in Asobi::Art applies too.
9
+ #
10
+ # Moving, turning or resizing a Many does the same to each of its
11
+ # pictures, so the answer is still a Many you can arrange. Colours given
12
+ # to the whole Many are kept on the group (+style+), and do not override
13
+ # colours given to the pictures inside it (as in SVG).
14
+ Many = Data.define(:items, :style) do
15
+ include Enumerable
16
+ include Art
17
+ include Node
18
+ undef_method :with
19
+
20
+ def initialize(items:, style: {})
21
+ super(items: items.map { Asobi.art(_1) }.freeze, style: Asobi::Style.clean(style))
22
+ end
23
+
24
+ # A Many as it is; any other picture as a group of one.
25
+ def self.of(art) = art.is_a?(Asobi::Many) ? art : new([art])
26
+
27
+ def each(&) = items.each(&)
28
+ def size = items.size
29
+ def [](index) = items[index]
30
+ def empty? = items.empty?
31
+
32
+ def box(m)
33
+ inner = Asobi::Box.union(items.map { _1.box(m) })
34
+ inner = inner.grow(Asobi::Style.stroke_width(style) / 2.0 * m.stretch) if inner && Asobi::Style.stroked?(style)
35
+ Asobi::Effects.grow(inner, style[:effects], m)
36
+ end
37
+
38
+ # The <defs> of the pictures inside are gathered to the front, once each.
39
+ def svg
40
+ inside = items.map(&:svg).join
41
+ return "" if inside.empty?
42
+
43
+ Asobi::Effects.render(style, inside, hoist: inside.include?("<defs>"), group: true) do
44
+ Asobi::Box.union(items.map { _1.box(IDENTITY) })
45
+ end
46
+ end
47
+
48
+ # --- arranging ---------------------------------------------------------
49
+ # Each answers a new Many with the same pictures in new places.
50
+
51
+ # Left to right, touching (or +gap+ apart), centred on the origin.
52
+ def in_row(gap_ = nil, gap: gap_ || 0)
53
+ Asobi::Check.number(gap, word: :in_row, name: :gap, example: "in_row(gap: 4)")
54
+ line_up(gap.to_f, :x)
55
+ end
56
+
57
+ # Top to bottom.
58
+ def in_column(gap_ = nil, gap: gap_ || 0)
59
+ Asobi::Check.number(gap, word: :in_column, name: :gap, example: "in_column(gap: 4)")
60
+ line_up(gap.to_f, :y)
61
+ end
62
+
63
+ # A table, filled row by row. Every cell is as big as the biggest
64
+ # picture. Without columns:, the table is about as wide as it is tall.
65
+ def in_grid(columns_ = nil, columns: columns_, gap: 0)
66
+ columns ||= [Math.sqrt(size).ceil, 1].max
67
+ columns = Asobi::Check.whole(columns, word: :in_grid, name: :columns, example: "in_grid(columns: 3)", min: 1)
68
+ Asobi::Check.number(gap, word: :in_grid, name: :gap, example: "in_grid(columns: 3, gap: 4)")
69
+ boxes = items.map { _1.box(IDENTITY) }
70
+ found = boxes.compact
71
+ cell_w = found.map(&:width).max.to_f + gap
72
+ cell_h = found.map(&:height).max.to_f + gap
73
+ used = [columns, size].min
74
+ rows = (size / columns.to_f).ceil
75
+ place do |art, _t, i|
76
+ b = boxes[i]
77
+ x = (i % columns - (used - 1) / 2.0) * cell_w
78
+ y = (i / columns - (rows - 1) / 2.0) * cell_h
79
+ b ? art.move(right: x - b.cx, down: y - b.cy) : art
80
+ end
81
+ end
82
+
83
+ # Evenly around a circle, clockwise from the top. Without radius:, the
84
+ # circle is just big enough that neighbours do not touch. With
85
+ # facing_out: true each picture turns so its top points outwards.
86
+ def in_ring(radius_ = nil, radius: radius_, facing_out: false)
87
+ radius ||= auto_ring_radius
88
+ Asobi::Check.number(radius, word: :in_ring, name: :radius, example: "in_ring(radius: 60)", min: 0)
89
+ place do |art, t|
90
+ angle = t * 360
91
+ art = home(art)
92
+ art = art.rotate(angle) if facing_out
93
+ x, y = Asobi::Geometry.polar(radius, angle)
94
+ art.move(right: x, down: y)
95
+ end
96
+ end
97
+
98
+ # Turn each picture a little more than the last, around the centre.
99
+ # (Move a petal off-centre, repeat it, fan it: a flower.) A fan of less
100
+ # than a full turn is spread evenly and centred on straight up.
101
+ def in_fan(over_ = nil, over: over_ || 360)
102
+ Asobi::Check.number(over, word: :in_fan, name: :over, example: "in_fan(over: 180)")
103
+ return self if empty?
104
+
105
+ if over.abs >= 360 || size == 1
106
+ place { |art, t| art.rotate(t * over) }
107
+ else
108
+ step = over.to_f / (size - 1)
109
+ place { |art, _t, i| art.rotate(i * step - over / 2.0) }
110
+ end
111
+ end
112
+
113
+ # A sunflower-seed spiral from the centre outwards. Without spacing:,
114
+ # it is the size of the biggest picture: the closest neighbours (at the
115
+ # centre) then just touch, and those further out have room to spare.
116
+ def in_spiral(spacing_ = nil, spacing: spacing_)
117
+ spacing ||= biggest_size
118
+ Asobi::Check.number(spacing, word: :in_spiral, name: :spacing, example: "in_spiral(spacing: 8)", min: 0)
119
+ golden = 180 * (3 - Math.sqrt(5)) # the golden angle, in degrees
120
+ place do |art, _t, i|
121
+ x, y = Asobi::Geometry.polar(spacing * Math.sqrt(i), i * golden)
122
+ home(art).move(right: x, down: y)
123
+ end
124
+ end
125
+
126
+ # Random places inside an area centred on the origin.
127
+ #
128
+ # scattered an area chosen from count and size
129
+ # scattered(over: 200) a 200 x 200 square
130
+ # scattered(over: [300, 100]) 300 wide, 100 tall
131
+ # scattered(seed: 7) the same picture every time, everywhere
132
+ #
133
+ # Without seed:, each run differs, but a run stays the same when the
134
+ # live view evaluates the sketch again (see Asobi::Random).
135
+ def scattered(over_ = nil, over: over_, seed: nil)
136
+ w, h = scatter_area(over)
137
+ random = Asobi::Random.generator(seed)
138
+ place do |art|
139
+ home(art).move(right: (random.rand - 0.5) * w, down: (random.rand - 0.5) * h)
140
+ end
141
+ end
142
+
143
+ # Change each picture where it stands. The block gets the picture, then
144
+ # t (0.0 for the first, approaching 1.0 for the last), then its number.
145
+ #
146
+ # dot.repeat(12).in_ring.change_each { |one, t| one.grow(to: 1 + t) }
147
+ def change_each(&block)
148
+ unless block
149
+ raise Asobi::Messages.error(ArgumentError, :need_block, word: :change_each,
150
+ example: "change_each { |one, t, i| one.rotate(t * 90) }")
151
+ end
152
+
153
+ results = items.each_with_index.map { |art, i| block.call(art, i.fdiv(size), i) }
154
+ kept = style
155
+ Asobi.gather(results) { |arts| Asobi::Many.new(arts, kept) }
156
+ end
157
+
158
+ private
159
+
160
+ def place
161
+ Asobi::Many.new(items.each_with_index.map { |art, i| yield art, i.fdiv(size), i }, style)
162
+ end
163
+
164
+ def line_up(gap, axis)
165
+ cursor = 0.0
166
+ placed = items.map do |art|
167
+ b = art.box(IDENTITY)
168
+ next [art, false] unless b
169
+
170
+ moved =
171
+ if axis == :x
172
+ art.move(right: cursor - b.x0, down: -b.cy)
173
+ else
174
+ art.move(right: -b.cx, down: cursor - b.y0)
175
+ end
176
+ cursor += (axis == :x ? b.width : b.height) + gap
177
+ [moved, true]
178
+ end
179
+ half = [cursor - gap, 0.0].max / 2.0
180
+ shifted = placed.map do |art, measured|
181
+ next art unless measured
182
+
183
+ axis == :x ? art.move(left: half) : art.move(up: half)
184
+ end
185
+ Asobi::Many.new(shifted, style)
186
+ end
187
+
188
+ # Arranging starts from scratch. Each picture is first brought home, the
189
+ # middle of its box onto the origin, so that arranging a group that was
190
+ # already arranged (a row made by to_art, say) gives the new arrangement
191
+ # and not a mix of the two.
192
+ def home(art)
193
+ b = art.box(IDENTITY)
194
+ return art unless b && (b.cx.abs > 1e-9 || b.cy.abs > 1e-9)
195
+
196
+ art.move(left: b.cx, up: b.cy)
197
+ end
198
+
199
+ # How far a picture reaches from the origin, at most (its box corners).
200
+ def reach(art)
201
+ b = art.box(IDENTITY)
202
+ return 0.0 unless b
203
+
204
+ [[b.x0, b.y0], [b.x1, b.y0], [b.x0, b.y1], [b.x1, b.y1]].map { |x, y| Math.hypot(x, y) }.max
205
+ end
206
+
207
+ # Neighbours sit 2 * sin(180 / n) * radius apart; make that a little
208
+ # more than the widest picture's reach on both sides.
209
+ def auto_ring_radius
210
+ r = items.map { reach(home(_1)) }.max.to_f
211
+ return 0.0 if r.zero?
212
+ return r * 1.1 if size <= 2
213
+
214
+ [(2.2 * r) / (2 * Math.sin(Math::PI / size)), r * 1.1].max
215
+ end
216
+
217
+ def biggest_size
218
+ sizes = items.filter_map { _1.box(IDENTITY) }.map { [_1.width, _1.height].max }
219
+ sizes.max || 10.0
220
+ end
221
+
222
+ def scatter_area(over)
223
+ if over.nil?
224
+ side = biggest_size * 2 * Math.sqrt([size, 1].max)
225
+ return [side, side]
226
+ end
227
+
228
+ w, h = over.is_a?(Array) ? over : [over, over]
229
+ ok = (over.is_a?(Array) ? over.size == 2 : true) && [w, h].all? { Asobi::Check.number?(_1) && _1 >= 0 }
230
+ raise Asobi::Messages.error(ArgumentError, :bad_area, got: over.inspect) unless ok
231
+
232
+ [w.to_f, h.to_f]
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # Every message a person might read, in English and Japanese.
5
+ #
6
+ # Errors are teachers: each one says what is wrong and shows a line of
7
+ # code that would work. The language follows the computer (LC_ALL, then
8
+ # LC_MESSAGES, then LANG) unless you choose one with Asobi.language = :ja.
9
+ module Messages
10
+ TEXTS = {
11
+ not_art: {
12
+ en: "%{thing} can't become a picture yet. Give its class a to_art method, like: def to_art = circle(radius: 10)",
13
+ ja: "%{thing} はまだ絵になれません。そのクラスに to_art を書いてください。例:def to_art = circle(radius: 10)"
14
+ },
15
+ to_art_loop: {
16
+ en: "to_art of %{thing} never answered a picture. Make to_art return a picture, like: def to_art = circle(radius: 10)",
17
+ ja: "%{thing} の to_art が絵を返しませんでした。to_art では絵を返してください。例:def to_art = circle(radius: 10)"
18
+ },
19
+ need_number: {
20
+ en: "%{word} needs a number for %{name}, like %{example}. You gave %{got}.",
21
+ ja: "%{word} の %{name} には数を渡してください(いまは %{got})。例:%{example}"
22
+ },
23
+ need_at_least: {
24
+ en: "%{word} needs %{name} to be %{min} or more, like %{example}. You gave %{got}.",
25
+ ja: "%{word} の %{name} には %{min} 以上の数を渡してください(いまは %{got})。例:%{example}"
26
+ },
27
+ need_between: {
28
+ en: "%{word} needs %{name} to be from %{min} to %{max}, like %{example}. You gave %{got}.",
29
+ ja: "%{word} の %{name} には %{min} から %{max} までの数を渡してください(いまは %{got})。例:%{example}"
30
+ },
31
+ need_whole: {
32
+ en: "%{word} needs a whole number of %{name} (%{min} or more), like %{example}. You gave %{got}.",
33
+ ja: "%{word} の %{name} には %{min} 以上の整数を渡してください(いまは %{got})。例:%{example}"
34
+ },
35
+ bad_color: {
36
+ en: "%{got} is not a colour asobi knows%{hint}. Use a CSS colour name or a colour code, like colored(:crimson) or colored(\"#ff0066\").",
37
+ ja: "%{got} は知らない色です%{hint}。CSS の色の名前か色のコードを渡してください。例:colored(:crimson)、colored(\"#ff0066\")"
38
+ },
39
+ did_you_mean: {
40
+ en: " (did you mean :%{name}?)",
41
+ ja: "(:%{name} のことですか?)"
42
+ },
43
+ bad_point: {
44
+ en: "path needs points as [x, y] pairs of numbers, like path([0, 0], [40, -30], [80, 0]). You gave %{got}.",
45
+ ja: "path の点は数2つの組 [x, y] で渡してください(いまは %{got})。例:path([0, 0], [40, -30], [80, 0])"
46
+ },
47
+ few_points: {
48
+ en: "path needs at least 2 points, like path([0, 0], [40, -30]). You gave %{got}.",
49
+ ja: "path には点を2つ以上渡してください(いまは %{got} 個)。例:path([0, 0], [40, -30])"
50
+ },
51
+ bad_area: {
52
+ en: "scattered needs an area as a number or [width, height], like scattered(over: 200) or scattered(over: [300, 100]). You gave %{got}.",
53
+ ja: "scattered の over には数か [横, 縦] を渡してください(いまは %{got})。例:scattered(over: 200)、scattered(over: [300, 100])"
54
+ },
55
+ bad_seed: {
56
+ en: "seed needs a whole number, like scattered(seed: 7). You gave %{got}.",
57
+ ja: "seed には整数を渡してください(いまは %{got})。例:scattered(seed: 7)"
58
+ },
59
+ bad_save: {
60
+ en: "save can write %{kinds} files, like save(\"flower.svg\"). You gave %{got}.",
61
+ ja: "save で書き出せるのは %{kinds} です(いまは %{got})。例:save(\"flower.svg\")"
62
+ },
63
+ need_block: {
64
+ en: "%{word} needs a block that changes one picture, like %{example}",
65
+ ja: "%{word} には1つの絵を変えるブロックを付けてください。例:%{example}"
66
+ },
67
+ endless: {
68
+ en: "%{got} never ends, so it can't become a picture. Give the range an end, like (1..10).to_art",
69
+ ja: "%{got} は終わりがないので絵にできません。終わりのある範囲にしてください。例:(1..10).to_art"
70
+ },
71
+ bad_paper: {
72
+ en: "paper needs [width, height], like paper: [800, 800]. You gave %{got}.",
73
+ ja: "paper には [横, 縦] を渡してください(いまは %{got})。例:paper: [800, 800]"
74
+ },
75
+ bad_language: {
76
+ en: "the language can be :en or :ja, like Asobi.language = :ja. You gave %{got}.",
77
+ ja: "言語は :en か :ja を選んでください(いまは %{got})。例:Asobi.language = :ja"
78
+ }
79
+ }
80
+
81
+ LANGUAGES = %i[en ja].freeze
82
+
83
+ module_function
84
+
85
+ # The language chosen with Asobi.language=, or else the computer's.
86
+ def language = @language || detect
87
+
88
+ def language=(value)
89
+ if value.nil?
90
+ @language = nil
91
+ return
92
+ end
93
+ lang = value.to_s.downcase[0, 2].to_sym if value.respond_to?(:to_s)
94
+ raise ArgumentError, text(:bad_language, got: value.inspect) unless LANGUAGES.include?(lang)
95
+
96
+ @language = lang
97
+ end
98
+
99
+ # LC_ALL wins over LC_MESSAGES, which wins over LANG; the first one that
100
+ # is set decides.
101
+ def detect(env = ENV)
102
+ setting = %w[LC_ALL LC_MESSAGES LANG].map { env[_1] }.find { _1 && !_1.empty? }
103
+ setting.to_s.downcase.start_with?("ja") ? :ja : :en
104
+ end
105
+
106
+ # Other files bring their own messages:
107
+ # Asobi::Messages.add(:need_motion, en: "...", ja: "...")
108
+ def add(key, en:, ja:)
109
+ TEXTS[key] = { en:, ja: }
110
+ end
111
+
112
+ def text(key, **vars)
113
+ table = TEXTS.fetch(key)
114
+ format(table.fetch(language), **vars)
115
+ end
116
+
117
+ # An exception carrying a message in the current language.
118
+ def error(klass, key, **vars) = klass.new(text(key, **vars))
119
+ end
120
+
121
+ def self.language = Messages.language
122
+ def self.language=(value)
123
+ Messages.language = value
124
+ end
125
+
126
+ # Checks for numbers given to the words. Each answers the value as a Float
127
+ # (or Integer for whole) or raises a teaching error.
128
+ module Check
129
+ module_function
130
+
131
+ def number?(value) = value.is_a?(Numeric) && value.real? && value.finite?
132
+
133
+ def number(value, word:, name:, example:, min: nil, max: nil)
134
+ raise Messages.error(TypeError, :need_number, **talk(value, word, name, example)) unless number?(value)
135
+
136
+ if min && max && !(min..max).cover?(value)
137
+ raise Messages.error(ArgumentError, :need_between, min:, max:, **talk(value, word, name, example))
138
+ end
139
+ if min && value < min
140
+ raise Messages.error(ArgumentError, :need_at_least, min:, **talk(value, word, name, example))
141
+ end
142
+
143
+ value.to_f
144
+ end
145
+
146
+ def whole(value, word:, name:, example:, min: 0)
147
+ ok = number?(value) && value == value.round && value >= min
148
+ raise Messages.error(ArgumentError, :need_whole, min:, **talk(value, word, name, example)) unless ok
149
+
150
+ value.round
151
+ end
152
+
153
+ def talk(value, word, name, example) = { word:, name:, example:, got: value.inspect }
154
+ end
155
+ end