magic_cloud 0.0.2
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 +7 -0
- data/LICENSE.txt +20 -0
- data/README.md +142 -0
- data/bin/magic_cloud +86 -0
- data/lib/magic_cloud.rb +7 -0
- data/lib/magic_cloud/bit_matrix.rb +33 -0
- data/lib/magic_cloud/canvas.rb +93 -0
- data/lib/magic_cloud/cloud.rb +137 -0
- data/lib/magic_cloud/collision_board.rb +110 -0
- data/lib/magic_cloud/debug.rb +28 -0
- data/lib/magic_cloud/layouter.rb +67 -0
- data/lib/magic_cloud/layouter/place.rb +109 -0
- data/lib/magic_cloud/palettes.rb +33 -0
- data/lib/magic_cloud/rect.rb +88 -0
- data/lib/magic_cloud/shape.rb +57 -0
- data/lib/magic_cloud/spriter.rb +90 -0
- data/lib/magic_cloud/version.rb +6 -0
- data/lib/magic_cloud/word.rb +30 -0
- data/magic_cloud.gemspec +40 -0
- data/samples/cat-in-the-hat.txt +2 -0
- data/samples/cat.png +0 -0
- metadata +136 -0
@@ -0,0 +1,110 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require_relative './bit_matrix'
|
3
|
+
|
4
|
+
module MagicCloud
|
5
|
+
# Pixel-by-pixel collision board
|
6
|
+
#
|
7
|
+
# Providen by width and height of the board, allows to check if given
|
8
|
+
# shape (array of zero and non-zero pixels) "collides" with any of
|
9
|
+
# previosly placed shapes
|
10
|
+
class CollisionBoard < BitMatrix
|
11
|
+
def initialize(width, height)
|
12
|
+
super
|
13
|
+
@rects = []
|
14
|
+
@intersections_cache = {}
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :rects, :intersections_cache
|
18
|
+
|
19
|
+
def criss_cross_collision?(rect)
|
20
|
+
if rects.any?{|r| r.criss_cross?(rect)}
|
21
|
+
Debug.stats[:criss_cross] += 1
|
22
|
+
true
|
23
|
+
else
|
24
|
+
false
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def collides_previous?(shape, intersections)
|
29
|
+
prev_idx = intersections_cache[shape.object_id]
|
30
|
+
|
31
|
+
if prev_idx && (prev = intersections[prev_idx]) &&
|
32
|
+
pixels_collision?(shape, prev)
|
33
|
+
|
34
|
+
Debug.stats[:px_prev_yes] += 1
|
35
|
+
true
|
36
|
+
else
|
37
|
+
false
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def pixels_collision_multi?(shape, intersections)
|
42
|
+
intersections.each_with_index do |intersection, idx|
|
43
|
+
next unless intersection
|
44
|
+
next if idx == intersections_cache[shape.object_id] # already checked it
|
45
|
+
|
46
|
+
next unless pixels_collision?(shape, intersection)
|
47
|
+
|
48
|
+
Debug.stats[:px_yes] += 1
|
49
|
+
intersections_cache[shape.object_id] = idx
|
50
|
+
return true
|
51
|
+
end
|
52
|
+
|
53
|
+
false
|
54
|
+
end
|
55
|
+
|
56
|
+
def collides?(shape)
|
57
|
+
Debug.stats[:collide_total] += 1
|
58
|
+
|
59
|
+
# nothing on board - so, no collisions
|
60
|
+
return false if rects.empty?
|
61
|
+
|
62
|
+
# no point to try drawing criss-crossed words
|
63
|
+
# even if they will not collide pixel-per-pixel
|
64
|
+
return true if criss_cross_collision?(shape.rect)
|
65
|
+
|
66
|
+
# then find which of placed sprites rectangles tag intersects
|
67
|
+
intersections = rects.map{|r| r.intersect(shape.rect)}
|
68
|
+
|
69
|
+
# no need to further check: this tag is not inside any others' rectangle
|
70
|
+
if intersections.compact.empty?
|
71
|
+
Debug.stats[:rect_no] += 1
|
72
|
+
return false
|
73
|
+
end
|
74
|
+
|
75
|
+
# most probable that we are still collide with this word
|
76
|
+
return true if collides_previous?(shape, intersections)
|
77
|
+
|
78
|
+
# only then check points inside intersected rectangles
|
79
|
+
return true if pixels_collision_multi?(shape, intersections)
|
80
|
+
|
81
|
+
Debug.stats[:px_no] += 1
|
82
|
+
|
83
|
+
false
|
84
|
+
end
|
85
|
+
|
86
|
+
def pixels_collision?(shape, rect)
|
87
|
+
l, t = shape.left, shape.top
|
88
|
+
(rect.x0...rect.x1).each do |x|
|
89
|
+
(rect.y0...rect.y1).each do |y|
|
90
|
+
dx = x - l
|
91
|
+
dy = y - t
|
92
|
+
return true if shape.sprite.at(dx, dy) && at(x, y)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
false
|
97
|
+
end
|
98
|
+
|
99
|
+
def add(shape)
|
100
|
+
l, t = shape.left, shape.top
|
101
|
+
shape.height.times do |dy|
|
102
|
+
shape.width.times do |dx|
|
103
|
+
put(l + dx, t + dy) if shape.sprite.at(dx, dy)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
rects << shape.rect
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'forwardable'
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module MagicCloud
|
6
|
+
# Debugging utilities class for cloud development itself
|
7
|
+
class Debug
|
8
|
+
class << self
|
9
|
+
def instance
|
10
|
+
@instance ||= new
|
11
|
+
end
|
12
|
+
|
13
|
+
extend Forwardable
|
14
|
+
def_delegators :instance, :logger, :stats, :reset!
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@logger = Logger.new(STDOUT).tap{|l| l.level = Logger::FATAL}
|
19
|
+
@stats = Hash.new{|h, k| h[k] = 0}
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :logger, :stats
|
23
|
+
|
24
|
+
def reset!
|
25
|
+
@stats = Hash.new{|h, k| h[k] = 0}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require_relative './collision_board'
|
3
|
+
|
4
|
+
module MagicCloud
|
5
|
+
# Main magic of magic cloud - layouting shapes without collisions.
|
6
|
+
# Also, alongside with CollisionBoard - the slowest and
|
7
|
+
# algorithmically trickiest part.
|
8
|
+
class Layouter
|
9
|
+
def initialize(w, h, options = {})
|
10
|
+
@board = CollisionBoard.new(w, h)
|
11
|
+
|
12
|
+
@options = options
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :board
|
16
|
+
|
17
|
+
def width
|
18
|
+
board.width
|
19
|
+
end
|
20
|
+
|
21
|
+
def height
|
22
|
+
board.height
|
23
|
+
end
|
24
|
+
|
25
|
+
def layout!(shapes)
|
26
|
+
visible_shapes = []
|
27
|
+
|
28
|
+
shapes.each do |shape|
|
29
|
+
next unless find_place(shape)
|
30
|
+
|
31
|
+
visible_shapes.push(shape)
|
32
|
+
end
|
33
|
+
|
34
|
+
visible_shapes
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def find_place(shape)
|
40
|
+
place = Place.new(self, shape)
|
41
|
+
start = Time.now
|
42
|
+
steps = 0
|
43
|
+
|
44
|
+
loop do
|
45
|
+
steps += 1
|
46
|
+
place.next!
|
47
|
+
|
48
|
+
next unless place.ready?
|
49
|
+
|
50
|
+
board.add(shape)
|
51
|
+
Debug.logger.info 'Place for %p found in %i steps (%.2f sec)' %
|
52
|
+
[shape, steps, Time.now-start]
|
53
|
+
|
54
|
+
break
|
55
|
+
end
|
56
|
+
|
57
|
+
true
|
58
|
+
rescue PlaceNotFound
|
59
|
+
Debug.logger.warn 'No place for %p found in %i steps (%.2f sec)' %
|
60
|
+
[shape, steps, Time.now-start]
|
61
|
+
|
62
|
+
false
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
require_relative './layouter/place'
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module MagicCloud
|
3
|
+
class Layouter
|
4
|
+
class PlaceNotFound < RuntimeError
|
5
|
+
end
|
6
|
+
|
7
|
+
# Incapsulating place lookup process
|
8
|
+
# 1. find initial random place
|
9
|
+
# 2. at each step, shift in spiral from the previous place
|
10
|
+
# 3. always knows, if the place "ready" for shape (empty and inside board)
|
11
|
+
class Place
|
12
|
+
def initialize(layouter, shape)
|
13
|
+
@layouter, @shape = layouter, shape
|
14
|
+
|
15
|
+
# initial position
|
16
|
+
@start_x = (
|
17
|
+
@layouter.width/2 + # from center
|
18
|
+
(rand-0.5) * # random shift
|
19
|
+
(@layouter.width - @shape.width) # not more than (cloud width - word width)
|
20
|
+
).to_i
|
21
|
+
@start_y = (
|
22
|
+
@layouter.height/2 + # from center
|
23
|
+
(rand-0.5) * # random shift
|
24
|
+
(@layouter.height - @shape.height) # not more than (cloud height - word height)
|
25
|
+
).to_i
|
26
|
+
|
27
|
+
# when shift of position is more than max delta (diagonal of cloud)
|
28
|
+
# there is no hope it will eventually found its place
|
29
|
+
@max_delta = Math.sqrt(@layouter.width**2 + @layouter.height**2)
|
30
|
+
|
31
|
+
# algo of next position calc
|
32
|
+
@spiral = make_spiral(@shape.size)
|
33
|
+
|
34
|
+
# direction of spiral
|
35
|
+
@dt = rand < 0.5 ? 1 : -1
|
36
|
+
|
37
|
+
# initial point of time before we start to look for place
|
38
|
+
@t = -@dt
|
39
|
+
end
|
40
|
+
|
41
|
+
def next!
|
42
|
+
@t += @dt
|
43
|
+
dx, dy = @spiral.call(@t)
|
44
|
+
|
45
|
+
fail PlaceNotFound if [dx, dy].map(&:abs).min > @max_delta
|
46
|
+
|
47
|
+
@shape.x = @start_x + dx
|
48
|
+
@shape.y = @start_y + dy
|
49
|
+
end
|
50
|
+
|
51
|
+
def ready?
|
52
|
+
!out_of_board? && !@layouter.board.collides?(@shape)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def out_of_board?
|
58
|
+
@shape.left < 0 || @shape.top < 0 ||
|
59
|
+
@shape.right > @layouter.width || @shape.bottom > @layouter.height
|
60
|
+
end
|
61
|
+
|
62
|
+
# FIXME: now we always use "rectangular spiral"
|
63
|
+
# d3.layout.cloud had two of them as an option - rectangular and
|
64
|
+
# archimedean. I assume, it should be checked if usage of two
|
65
|
+
# spirals produce significantly different results, and then
|
66
|
+
# either one spiral should left, or it should became an option.
|
67
|
+
def make_spiral(step)
|
68
|
+
rectangular_spiral(step)
|
69
|
+
end
|
70
|
+
|
71
|
+
# rubocop:disable Metrics/AbcSize
|
72
|
+
def archimedean_spiral(size)
|
73
|
+
e = width / height
|
74
|
+
->(t){
|
75
|
+
t1 = t * size * 0.01
|
76
|
+
|
77
|
+
[
|
78
|
+
e * t1 * Math.cos(t1),
|
79
|
+
t1 * Math.sin(t1)
|
80
|
+
].map(&:round)
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
def rectangular_spiral(size)
|
85
|
+
dy = 4 * size * 0.1
|
86
|
+
dx = dy * @layouter.width / @layouter.height
|
87
|
+
x = 0
|
88
|
+
y = 0
|
89
|
+
->(t){
|
90
|
+
sign = t < 0 ? -1 : 1
|
91
|
+
|
92
|
+
# zverok: this is original comment & code from d3.layout.cloud.js
|
93
|
+
# Looks too witty for me.
|
94
|
+
#
|
95
|
+
# See triangular numbers: T_n = n * (n + 1) / 2.
|
96
|
+
case (Math.sqrt(1 + 4 * sign * t) - sign).to_i & 3
|
97
|
+
when 0 then x += dx
|
98
|
+
when 1 then y += dy
|
99
|
+
when 2 then x -= dx
|
100
|
+
else y -= dy
|
101
|
+
end
|
102
|
+
|
103
|
+
[x, y].map(&:round)
|
104
|
+
}
|
105
|
+
end
|
106
|
+
# rubocop:enable Metrics/AbcSize
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module MagicCloud
|
3
|
+
PALETTES = {
|
4
|
+
# Categorical colors from d3
|
5
|
+
# Source: https://github.com/mbostock/d3/wiki/Ordinal-Scales#categorical-colors
|
6
|
+
|
7
|
+
category10: %w[
|
8
|
+
#1f77b4 #ff7f0e #2ca02c #d62728 #9467bd
|
9
|
+
#8c564b #e377c2 #7f7f7f #bcbd22 #17becf
|
10
|
+
],
|
11
|
+
|
12
|
+
category20: %w[
|
13
|
+
#1f77b4 #aec7e8 #ff7f0e #ffbb78 #2ca02c
|
14
|
+
#98df8a #d62728 #ff9896 #9467bd #c5b0d5
|
15
|
+
#8c564b #c49c94 #e377c2 #f7b6d2 #7f7f7f
|
16
|
+
#c7c7c7 #bcbd22 #dbdb8d #17becf #9edae5
|
17
|
+
],
|
18
|
+
|
19
|
+
category20b: %w[
|
20
|
+
#393b79 #5254a3 #6b6ecf #9c9ede #637939
|
21
|
+
#8ca252 #b5cf6b #cedb9c #8c6d31 #bd9e39
|
22
|
+
#e7ba52 #e7cb94 #843c39 #ad494a #d6616b
|
23
|
+
#e7969c #7b4173 #a55194 #ce6dbd #de9ed6
|
24
|
+
],
|
25
|
+
|
26
|
+
category20c: %w[
|
27
|
+
#3182bd #6baed6 #9ecae1 #c6dbef #e6550d
|
28
|
+
#fd8d3c #fdae6b #fdd0a2 #31a354 #74c476
|
29
|
+
#a1d99b #c7e9c0 #756bb1 #9e9ac8 #bcbddc
|
30
|
+
#dadaeb #636363 #969696 #bdbdbd #d9d9d9
|
31
|
+
]
|
32
|
+
}
|
33
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module MagicCloud
|
3
|
+
# Utility geometrical rectangle, implementing arithmetic interactions
|
4
|
+
# with other rectangles
|
5
|
+
# (not to be confused with drawable shapes)
|
6
|
+
class Rect
|
7
|
+
def initialize(x0, y0, x1, y1)
|
8
|
+
@x0, @y0, @x1, @y1 = x0, y0, x1, y1
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_accessor :x0, :y0, :x1, :y1
|
12
|
+
# NB: we are trying to use instance variables instead of accessors
|
13
|
+
# inside this class methods, because they are called so many
|
14
|
+
# times that accessor overhead IS significant.
|
15
|
+
|
16
|
+
def width
|
17
|
+
@x1 - @x0
|
18
|
+
end
|
19
|
+
|
20
|
+
def height
|
21
|
+
@y1 - @y0
|
22
|
+
end
|
23
|
+
|
24
|
+
def collide?(other)
|
25
|
+
@x1 > other.x0 &&
|
26
|
+
@x0 < other.x1 &&
|
27
|
+
@y1 > other.y0 &&
|
28
|
+
@y0 < other.y1
|
29
|
+
end
|
30
|
+
|
31
|
+
# shift to new coords, preserving the size
|
32
|
+
def move_to(x, y)
|
33
|
+
@x1 += x - @x0
|
34
|
+
@y1 += y - @y0
|
35
|
+
@x0 = x
|
36
|
+
@y0 = y
|
37
|
+
end
|
38
|
+
|
39
|
+
# rubocop:disable Metrics/AbcSize
|
40
|
+
def adjust!(other)
|
41
|
+
@x0 = other.x0 if other.x0 < @x0
|
42
|
+
@y0 = other.y0 if other.y0 < @y0
|
43
|
+
@x1 = other.x1 if other.x1 > @x1
|
44
|
+
@y1 = other.y1 if other.y1 > @y1
|
45
|
+
end
|
46
|
+
# rubocop:enable Metrics/AbcSize
|
47
|
+
|
48
|
+
def adjust(other)
|
49
|
+
dup.tap{|d| d.adjust!(other)}
|
50
|
+
end
|
51
|
+
|
52
|
+
# rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity,Metrics/AbcSize
|
53
|
+
def criss_cross?(other)
|
54
|
+
# case 1: this one is horizontal:
|
55
|
+
# overlaps other by x, to right and left, and goes inside it by y
|
56
|
+
@x0 < other.x0 && @x1 > other.x1 &&
|
57
|
+
@y0 > other.y0 && @y1 < other.y1 ||
|
58
|
+
# case 2: this one is vertical:
|
59
|
+
# overlaps other by y, to top and bottom, and goes inside it by x
|
60
|
+
@y0 < other.y0 && @y1 > other.y1 &&
|
61
|
+
@x0 > other.x0 && @x1 < other.x1
|
62
|
+
end
|
63
|
+
# rubocop:enable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity,Metrics/AbcSize
|
64
|
+
|
65
|
+
def intersect(other)
|
66
|
+
# direct comparison is dirtier, yet significantly faster than
|
67
|
+
# something like [@x0, other.x0].max
|
68
|
+
ix0 = @x0 > other.x0 ? @x0 : other.x0
|
69
|
+
ix1 = @x1 < other.x1 ? @x1 : other.x1
|
70
|
+
iy0 = @y0 > other.y0 ? @y0 : other.y0
|
71
|
+
iy1 = @y1 < other.y1 ? @y1 : other.y1
|
72
|
+
|
73
|
+
if ix0 > ix1 || iy0 > iy1
|
74
|
+
nil # rectangles are not intersected, in fact
|
75
|
+
else
|
76
|
+
Rect.new(ix0, iy0, ix1, iy1)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def inspect
|
81
|
+
"#<Rect[#{x0},#{y0};#{x1},#{y1}]>"
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_s
|
85
|
+
"#<Rect[#{x0},#{y0};#{x1},#{y1}]>"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
module MagicCloud
|
3
|
+
# Basic "abstract shape" class, with all primitive functionality
|
4
|
+
# necessary for use it in Spriter and Layouter.
|
5
|
+
#
|
6
|
+
# Word for wordcloud is inherited from it, and its potentially
|
7
|
+
# possible to inherit other types of shapes and layout them also.
|
8
|
+
class Shape
|
9
|
+
def initialize
|
10
|
+
@x = 0
|
11
|
+
@y = 0
|
12
|
+
@sprite = nil
|
13
|
+
@rect = nil
|
14
|
+
@width = 0
|
15
|
+
@height = 0
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :sprite, :x, :y, :width, :height, :rect
|
19
|
+
|
20
|
+
def sprite=(sprite)
|
21
|
+
@sprite = sprite
|
22
|
+
@width = sprite.width
|
23
|
+
@height = sprite.height
|
24
|
+
@rect = Rect.new(left, top, right, bottom)
|
25
|
+
end
|
26
|
+
|
27
|
+
def x=(newx)
|
28
|
+
@x = newx
|
29
|
+
@rect.move_to(@x, @y)
|
30
|
+
end
|
31
|
+
|
32
|
+
def y=(newy)
|
33
|
+
@y = newy
|
34
|
+
@rect.move_to(@x, @y)
|
35
|
+
end
|
36
|
+
|
37
|
+
def left
|
38
|
+
x
|
39
|
+
end
|
40
|
+
|
41
|
+
def right
|
42
|
+
x + width
|
43
|
+
end
|
44
|
+
|
45
|
+
def top
|
46
|
+
y
|
47
|
+
end
|
48
|
+
|
49
|
+
def bottom
|
50
|
+
y + height
|
51
|
+
end
|
52
|
+
|
53
|
+
def draw(_canvas)
|
54
|
+
fail NotImplementedError
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|