supply_height = 96
CLICK_FUZ = 10 # this far away from things is close enough to be "clicked on"
PROX_MAX = CLICK_FUZ * CLICK_FUZ
+PROX_TOO_FAR = PROX_MAX + 1 # no need to be precice when it's too far
# constants
-STYLE_NORMAL = 0
-STYLE_SELECTED = 1
-STYLE_HOVER = 2
-STYLE_EDITING = 3
-STYLE_DRAGGING = 4
-
-STYLE_TO_CLASS = [
- "normal"
- "selected"
- "hover"
- "editing"
- "dragging"
-]
-
-set_style_class = (args) ->
- args.el.setAttribute 'class', "#{args.class} #{STYLE_TO_CLASS[args.style]}"
+STATES = {
+ NORMAL: { txt: 'normal' }
+ SELECTED: { txt: 'selected' }
+ DRAGGING: { txt: 'dragging' }
+ EDITING: { txt: 'editing' }
+}
+TYPE_UNKNOWN = 0
+TYPE_WIDGET = 1
+TYPE_CONTROL = 2
# json (compiled to javascript and minified) is ~8% smaller than the resulting xml
json_to_svg = (json) ->
el.setAttribute k, v
return el
+resizer_nw = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width - dxy.x, h: widget.height - dxy.y
+ widget.move x: widget.x + dxy.x, y: widget.y + dxy.y
+resizer_n = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width, h: widget.height - dxy.y
+ widget.move x: widget.x, y: widget.y + dxy.y
+resizer_ne = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width + dxy.x, h: widget.height - dxy.y
+ widget.move x: widget.x, y: widget.y + dxy.y
+resizer_e = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width + dxy.x, h: widget.height
+resizer_se = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width + dxy.x, h: widget.height + dxy.y
+resizer_s = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width, h: widget.height + dxy.y
+resizer_sw = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width - dxy.x, h: widget.height + dxy.y
+ widget.move x: widget.x + dxy.x, y: widget.y
+resizer_w = (widget) ->
+ return (dxy) ->
+ widget.resize w: widget.width - dxy.x, h: widget.height
+ widget.move x: widget.x + dxy.x, y: widget.y
+resizers = [
+ resizer_nw
+ resizer_n
+ resizer_ne
+ resizer_e
+ resizer_se
+ resizer_s
+ resizer_sw
+ resizer_w
+]
+resizer_shapes = [
+ -> return "M#{@x - 5} #{@y - 5}h6l-2 2 4 4 2 -2v6h-6l2-2-4-4-2 2z"
+ -> return "M #{@x},#{@y - 7} l 4,4 -2.5,0 0,5 2.5,0 -4,4 -4,-4 2.5,0 0,-5 -2.5,0 z"
+ -> return "M#{@x + 5} #{@y - 5}v6l-2-2-4 4 2 2h-6v-6l2 2 4-4-2-2z"
+ -> return "M #{@x - 7},#{@y} l 4,-4 0,2.5 5,0 0,-2.5 4,4 -4,4 0,-2.5 -5,0 0,2.5 z"
+]
+shape_node_move = -> "M#{@x} #{@y - 9}l-2.5 4.5h2v2.404a2.156 2.156 0 0 0-1.596 1.596h-2.404v-2l-4.5 2.5 4.5 2.5v-2h2.404a2.156 2.156 0 0 0 1.596 1.596v2.404h-2l2.5 4.5 2.5-4.5h-2v-2.404a2.156 2.156 0 0 0 1.596-1.596h2.404v2l4.5-2.5-4.5-2.5v2h-2.404a2.156 2.156 0 0 0-1.596-1.596v-2.404h2l-2.5-4.5z"
+
next_widget_id = 0
# public vars: x, y, width, height, el
class Visible
# required args: svg
constructor: (args) ->
+ @type = TYPE_UNKNOWN
@id = next_widget_id
next_widget_id += 1
@svg = args.svg
@y = args.y ? 1
@width = args.width ? 50
@height = args.height ? 34
- @style = args.style ? STYLE_NORMAL
+ @state = args.state ? STATES.NORMAL
+ @hover = false
+ @shown = false
+ @el = null
+ return
destruct: ->
- move: (args) ->
- @x = args.x
- @y = args.y
+ @hide()
+ return
+ update_class: ->
+ css_class = "#{@css_class} #{@state.txt}"
+ if @hover
+ css_class += " hover"
+ @el.setAttribute 'class', css_class
+ return
+ set_hover: (bool) ->
+ if bool != @hover
+ @hover = bool
+ @update_class()
+ return
+ hide: ->
+ if @el and @shown
+ @svg.removeChild @el
+ @shown = false
+ return
+ show: ->
+ if @el? and not @shown
+ @svg.appendChild @el
+ @shown = true
+ return
+ move: (xy) -> # just move
+ @x = xy.x
+ @y = xy.y
+ return
+ drag: (dxy) -> # react to mouse drag (obey constraints, etc.)
+ @move x: @x + dxy.x, y: @y + dxy.y
+ return
proximity: (xy) -> # return the square of the distance to your visible bits
- return PROX_MAX + 1
- set_style: (style) ->
- @style = style
+ return PROX_TOO_FAR
+ set_state: (state) ->
+ @state = state
+ @update_class()
+ return
class Control extends Visible
+ constructor: (args) ->
+ super args
+ @type = TYPE_CONTROL
+ @on_drag = args.drag
+ return
+ drag: (args) -> # call this when control point is being manipulated directly
+ @on_drag args
+ return
+ proximity: (xy) -> # return the square of the distance to your visible bits
+ dx = xy.x - @x
+ dy = xy.y - @y
+ return dx * dx + dy * dy
+class ControlPath extends Control
+ constructor: (args) ->
+ super args
+ @css_class = 'control_point'
+ @make_path = args.shape
+ @el = json_to_svg path:
+ d: @make_path()
+ class: 'control_point normal'
+ return
+ move: (args) ->
+ super args
+ @el.setAttribute 'd', @make_path()
+ return
+# A widget is a visable thing that can be edited via controls
class Widget extends Visible
- #sub-classes are expected to implement all of these:
+ constructor: (args) ->
+ super args
+ @type = TYPE_WIDGET
+ @controls = null
+ @controls_shown = false
+ destruct: ->
+ super()
+ @hide_controls()
+ return
clone: ->
return new Widget @
- controls: -> # create controls, return them
- return []
+ make_controls: ->
+ @controls = []
+ return
hide_controls: ->
+ return unless @controls? and @controls_shown
+ for c in @controls
+ c.hide()
+ @controls_shown = false
+ return
+ show_controls: ->
+ return if @controls? and @controls_shown
+ unless @controls?
+ @make_controls()
+ return unless @controls?
+ for c in @controls
+ c.show()
+ @controls_shown = true
+ return
+ move: (xy) ->
+ dx = xy.x - @x
+ dy = xy.y - @y
+ super xy
+ if @controls?
+ for c in @controls
+ c.move x: c.x + dx, y: c.y + dy
+ return
+ set_state: (state) ->
+ return if @state is state
+ if @state is STATES.EDITING
+ @hide_controls()
+ super state
+ return
class RectWidget extends Widget
constructor: (args) ->
width: @width - 2
height: @height - 2
class: 'box normal'
- @svg.appendChild @el
- destruct: ->
- super()
- if @el?
- @svg.removeChild @el
+ @show()
+ return
clone: ->
return new RectWidget @
- set_style: (style) ->
- super style
- set_style_class el: @el, class: 'box', style: style
+ as_array: ->
+ return [@x, @y, @width, @height]
+ from_array: (args, a) ->
+ args.x = a[0]
+ args.y = a[1]
+ args.width = a[2]
+ args.height = a[3]
+ return new RectWidget args
move: (args) ->
super args
@el.setAttribute 'x', @x + 1
@el.setAttribute 'y', @y + 1
+ @reposition_controls()
+ return
proximity: (xy) -> # return the square of the distance to your visible bits
x = xy.x
y = xy.y
- prox = PROX_MAX + 1
+ prox = PROX_TOO_FAR
in_x = false
in_y = false
if x > @x - CLICK_FUZ and x < @x + @width + CLICK_FUZ
new_prox *= new_prox
if new_prox < prox
prox = new_prox
- if in_x and in_y and prox > PROX_MAX
- prox = PROX_MAX - 1
+ # "hit" anything inside
+ #if in_x and in_y and prox > PROX_MAX
+ # prox = PROX_MAX - 1
return prox
- controls: -> # create controls, return them
- return []
- hide_controls: ->
+ resize: (wh) ->
+ dw = wh.w - @width
+ dh = wh.h - @height
+ @width = wh.w
+ @el.setAttribute 'width', @width - 2
+ @height = wh.h
+ @el.setAttribute 'height', @height - 2
+ @reposition_controls()
+ return
+ reposition_controls: ->
+ if @controls? and @controls.length > 1
+ positions = @control_positions()
+ for i in [0...positions.length]
+ @controls[i].move x: positions[i].x, y: positions[i].y
+ return
+ control_positions: ->
+ gap = 7
+ mgap = 9
+ w2p = Math.floor(@width / 2) + 0.5
+ h2p = Math.floor(@height / 2) + 0.5
+ return [
+ { x: @x - gap, y: @y - gap }
+ { x: @x + w2p, y: @y - mgap }
+ { x: @x + @width + gap, y: @y - gap }
+ { x: @x + @width + mgap, y: @y + h2p }
+ { x: @x + @width + gap, y: @y + @height + gap }
+ { x: @x + w2p, y: @y + @height + mgap }
+ { x: @x - gap, y: @y + @height + gap }
+ { x: @x - mgap, y: @y + h2p }
+ ]
+ make_controls: ->
+ @controls = []
+ positions = @control_positions()
+ for i in [0...positions.length]
+ @controls.push new ControlPath {
+ svg: @svg
+ x: positions[i].x
+ y: positions[i].y
+ drag: resizers[i] @
+ shape: resizer_shapes[i % resizer_shapes.length]
+ }
+ return
+class PolylineWidget extends Widget
+ constructor: (args) ->
+ super args
+ @css_class = 'polyline'
+ @nodes = []
+ for n in args.nodes
+ @nodes.push x: n.x, y: n.y
+ @el = json_to_svg path:
+ d: @my_path_d()
+ class: 'polyline normal'
+ @show()
+ as_array: ->
+ ret = []
+ for n in @nodes
+ ret.push @x + n.x
+ ret.push @y + n.y
+ return ret
+ from_array: (args, a) ->
+ # the first node is also used as the @x,@y
+ args.x = a[0]
+ args.y = a[1]
+ args.nodes = []
+ while a.length
+ xy = {}
+ xy.x = a.shift() - args.x
+ xy.y = a.shift() - args.y
+ args.nodes.push xy
+ return new PolylineWidget args
+ clone: ->
+ return new PolylineWidget @
+ my_path_d: ->
+ ret = ''
+ for n in @nodes
+ if ret is ''
+ ret += 'M'
+ else
+ ret += 'L'
+ ret += n.x + @x
+ ret += ' '
+ ret += n.y + @y
+ return ret
+ move: (args) ->
+ super args
+ @el.setAttribute 'd', @my_path_d()
+ @reposition_controls()
+ return
+ proximity: (xy) -> # return the square of the distance to your visible bits
+ prox = PROX_TOO_FAR
+ for n, i in @nodes
+ dx = @x + n.x - xy.x
+ dy = @y + n.y - xy.y
+ p = dx * dx + dy * dy
+ if p < prox
+ prox = p
+ if i > 0
+ l1x = @x + @nodes[i-1].x
+ l1y = @y + @nodes[i-1].y
+ l2x = @x + @nodes[i].x
+ l2y = @y + @nodes[i].y
+ ldx = l2x - l1x
+ ldy = l2y - l1y
+ if ldx is 0 # vertical line
+ if (xy.y < l1y) is (xy.y < l2y)
+ continue
+ dx = l1x - xy.x
+ p = dx * dx
+ else if ldy is 0 # horizontal line
+ if (xy.x < l1x) is (xy.x < l2x)
+ continue
+ dy = l1y - xy.y
+ p = dy * dy
+ else # slanty line
+ # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
+ a = ldy
+ b = -1 * ldx
+ c = l2x * l1y - l2y * l1x
+ y_on_line = (a * (a * xy.y - b * xy.x) - b * c) / (a * a + b * b)
+ if (y_on_line < l1y) is (y_on_line < l2y)
+ continue
+ p = (a * xy.x + b * xy.y + c) / Math.sqrt(a * a + b * b)
+ p *= p
+
+ if p < prox
+ prox = p
+ return prox
+ resize: (wh) ->
+ # TODO implement
+ #@el.setAttribute 'd', @my_path_d()
+ #@reposition_controls()
+ return
+ node_dragger: (i) ->
+ if i is 0
+ return (dxy) =>
+ for j in [1...@nodes.length]
+ @nodes[j].x -= dxy.x
+ @nodes[j].y -= dxy.y
+ @move x: @x + dxy.x, y: @y + dxy.y
+ return (dxy) =>
+ @nodes[i].x += dxy.x
+ @nodes[i].y += dxy.y
+ @el.setAttribute 'd', @my_path_d()
+ @reposition_controls()
+ reposition_controls: ->
+ if @controls? and @controls.length > 1
+ positions = @control_positions()
+ for i in [0...positions.length]
+ @controls[i].move x: positions[i].x, y: positions[i].y
+ return
+ control_positions: ->
+ ret = []
+ for n in @nodes
+ ret.push x: @x + n.x, y: @y + n.y
+ return ret
+ make_controls: ->
+ @controls = []
+ positions = @control_positions()
+ for i in [0...positions.length]
+ @controls.push new ControlPath {
+ svg: @svg
+ x: positions[i].x
+ y: positions[i].y
+ drag: @node_dragger i
+ shape: shape_node_move
+ }
+ return
+
+CSS_CLASS_TO_PICKLE_TYPE = {
+ box: '0'
+ polyline: '1'
+}
+PICKLE_TYPE_TO_WIDGET_CLASS = [
+ RectWidget
+ PolylineWidget
+]
# called automatically on domcontentloaded
init = ->
svg_offset = null
]
svg.appendChild json_to_svg style:
type: 'text/css'
- contents: '.box.normal,.box.hover,.box.selected{filter: url(#crayon)}'
+ contents: '.box.normal,.polyline.normal{filter: url(#crayon)}'
# create canvas border
svg.appendChild json_to_svg rect:
class: 'canvas_border'
supply = {}
- for args, i in [
- { width: 40, height: 40 }
- { width: 12, height: 50 }
- { width: 70, height: 12 }
- ]
- widget = new RectWidget {
- width: args.width
- height: args.height
- x: 30 + i * 90 + (70 - args.width) / 2
- y: (supply_height - args.height) / 2
- svg: svg
- }
- supply[widget.id] = widget
+ supply_count = 0
+ supply_add = (type, args) ->
+ args.x ?= 0
+ args.y ?= 0
+ args.x += 30 + supply_count * 90
+ args.y += (supply_height - 50) / 2
+ args.svg = svg
+ w = new type args
+ supply[w.id] = w
+ supply_count += 1
+ supply_add RectWidget, width: 50, height: 50
+ supply_add PolylineWidget, y: 25, nodes: [{x: 0, y: 0}, {x: 50, y: 0}]
+ supply_add PolylineWidget, x: 25, nodes: [{x: 0, y: 0}, {x: 0, y: 50}]
+ supply_add PolylineWidget, x: 10, nodes: [{x: 0, y: 0}, {x: 15, y: 50}, {x: 30, y: 0}]
+ supply_add PolylineWidget, y: 50, nodes: [{x: 0, y: 0}, {x: 17, y: -50}, {x: 33, y: 0}, {x: 50, y: -50}]
# editor state
- on_canvas = {}
+ widgets = {}
selected = {}
- editing = {} # has controls
- dragging = false
+ selected_count = 0
+ hovered = null
+ drag_targets = null
+ dragging = false # mouse state
drag_from = x: 0, y: 0 # mouse was here at last frame of drag
shift_key_down = false
- deselect_all = (args) ->
- except = args?.except ? null
+ lc = "abcdefghijklmnopqrstuvwxyz"
+ uc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ cc_lca = lc.charCodeAt 0
+ cc_lcz = lc.charCodeAt 25
+ cc_uca = uc.charCodeAt 0
+ cc_ucz = uc.charCodeAt 25
+ cc_0 = '0'.charCodeAt 0
+ cc_9 = '9'.charCodeAt 0
+ # takes an array of positive integers, and encodes it as a string
+ pickle = (a) ->
+ ret = ''
+ for i in a
+ cs = lc
+ r = ''
+ while i > 0 or r is ''
+ digit = i % cs.length
+ i -= digit
+ i /= cs.length
+ r = cs.charAt(digit) + r
+ cs = uc
+ ret += r
+ return ret
+ pickle_widgets = ->
+ ret = '0' # version of this encoding scheme
+ for id, w of widgets
+ if CSS_CLASS_TO_PICKLE_TYPE[w.css_class]?
+ ret += CSS_CLASS_TO_PICKLE_TYPE[w.css_class]
+ ret += pickle w.as_array()
+ return ret
+ save = ->
+ window.location.hash = pickle_widgets()
+ load = (str) ->
+ return if str.charAt(1) isnt '0'
+ wtype = null
+ args = []
+ ii = 0
+ load_1 = (next_type) ->
+ unless wtype?
+ if next_type?
+ wtype = next_type
+ return
+ w = PICKLE_TYPE_TO_WIDGET_CLASS[wtype]::from_array svg: svg, args
+ widgets[w.id] = w
+ wtype = next_type
+ args = []
+ ii = 0
+ for i in [2...str.length]
+ c = str.charCodeAt(i)
+ if cc_0 <= c <= cc_9
+ load_1 c - cc_0
+ else if cc_lca <= c <= cc_lcz
+ ii *= lc.length
+ ii += c - cc_lca
+ args.push ii
+ ii = 0
+ else if cc_uca <= c <= cc_ucz
+ ii *= lc.length
+ ii += c - cc_uca
+ load_1 null
+ return
+ stop_editing = ->
+ for id, w of widgets
+ w.hide_controls()
+ return
+ deselect = (w) ->
+ return unless selected[w.id]?
+ w.set_state STATES.NORMAL
+ delete selected[w.id]
+ selected_count -= 1
+ w.hide_controls()
+ return
+ deselect_all = (except = null) ->
for id, w of selected
- w.set_style STYLE_NORMAL
- delete selected[id]
- closest_widget = (widgets, xy) ->
- prox = PROX_MAX + 1
+ if w isnt except
+ deselect w
+ return
+ _select = (w) -> # don't call this directly, use select_only() or select_also()
+ w.set_state STATES.SELECTED
+ selected[w.id] = w
+ return
+ select_only = (w) ->
+ deselect_all w
+ return if selected[w.id]?
+ _select w
+ return
+ select_also = (w) ->
+ return if selected[w.id]?
+ stop_editing()
+ _select w
+ return
+ find_closest = (widgets, xy) ->
+ prox = PROX_TOO_FAR
closest = null
for id, w of widgets
new_prox = w.proximity xy
if new_prox < prox
prox = new_prox
closest = w
- if prox < PROX_MAX
- return closest
- return null
+ if prox > PROX_MAX
+ return null
+ return closest
+ find_closest_control = (xy) ->
+ prox = PROX_TOO_FAR
+ closest = null
+ for id, s of selected
+ if s.controls_shown
+ for c in s.controls
+ if c.shown
+ new_prox = c.proximity xy
+ if new_prox < prox
+ prox = new_prox
+ closest = c
+ if prox > PROX_MAX
+ return null
+ return closest
+ find_closest_thing = (xy) ->
+ if xy.y < supply_height
+ return find_closest supply, xy
+ else
+ hit = find_closest_control xy
+ hit ?= find_closest widgets, xy
+ return hit
svg_event_to_xy = (e) ->
unless svg_offset?
svg_offset = $svg.offset()
y: Math.round(e.pageY - svg_offset.top)
}
mousedown = (e) ->
+ hit = null
+ closest = null
mousemove e
if dragging # two mousedowns in a row?! it happens
return mouseup e
xy = svg_event_to_xy e
if xy.y < supply_height
- closest = closest_widget supply, xy
- if closest?
- closest = closest.clone()
- on_canvas[closest.id] = closest
+ hit = find_closest supply, xy
+ if hit?
+ hit = hit.clone()
+ widgets[hit.id] = hit
else
- closest = closest_widget on_canvas, xy
- if closest?
- unless (shift_key_down or selected[closest.id]?)
- deselect_all except: closest
- selected[closest.id] = closest
- closest.set_style STYLE_DRAGGING
+ hit = find_closest_control xy
+ unless hit?
+ hit = find_closest widgets, xy
+ if hit?
+ if hit.type is TYPE_WIDGET
+ if selected[hit.id]
+ # already selected
+ # TODO start detection of a click that doesn't drag (to shrink selection)
+ else if xy.y < supply_height
+ # dragging a new thing in
+ select_only hit
+ else if shift_key_down
+ select_also hit
+ else
+ select_only hit
+ for id, w of selected
+ w.set_state STATES.DRAGGING
+ drag_targets = selected
+ else
+ drag_targets = {}
+ drag_targets[hit.id] = hit
dragging = true
drag_from = xy
else
deselect_all()
- return false
+ return
mouseup = (e) ->
+ save()
mousemove e
if dragging
- for id, w of selected
- if w.y < supply_height
- w.destruct()
- delete selected[id]
- else
- w.set_style STYLE_SELECTED
+ widgets_remaining = 0
+ for id, s of drag_targets
+ if s.type is TYPE_WIDGET
+ if s.y < supply_height
+ deselect s
+ s.destruct()
+ delete widgets[id]
+ else
+ widgets_remaining += 1
+ s.set_state STATES.SELECTED
+ if widgets_remaining is 1
+ for id, s of drag_targets
+ if s.type is TYPE_WIDGET
+ s.set_state STATES.EDITING
+ s.show_controls()
dragging = false
- return false
- prev_hover = null
+ return
mousemove = (e) ->
xy = svg_event_to_xy e
if dragging
rel_x = xy.x - drag_from.x
rel_y = xy.y - drag_from.y
drag_from = xy
- for id, w of selected
- w.move x: w.x + rel_x, y: w.y + rel_y
+ for id, w of drag_targets
+ w.drag x: rel_x, y: rel_y
else
- hover = closest_widget on_canvas, xy
- unless hover?
- hover = closest_widget supply, xy
- if hover != prev_hover
- if prev_hover?
- # FIXME
- if selected[prev_hover.id]?
- prev_hover.set_style STYLE_SELECTED
- else
- prev_hover.set_style STYLE_NORMAL
- if hover?
- hover.set_style STYLE_HOVER
- prev_hover = hover
+ hit = find_closest_thing xy
+ return if hit is hovered # both null, or both the same
+ return if hit?.type is TYPE_CONTROL # hovering a control doesn't change display
+ if hovered?
+ hovered.set_hover false
+ if hit?
+ hovered = hit
+ hit.set_hover true
+ return
+ $svg.mousedown (e) ->
+ mousedown e
+ return false
+ $svg.mouseup (e) ->
+ mouseup e
+ return false
+ $svg.mousemove (e) ->
+ mousemove e
return false
- $svg.mousedown mousedown
- $svg.mouseup mouseup
- $svg.mousemove mousemove
$(document).on 'keyup keydown', (e) ->
shift_key_down = e.shiftKey
return true
#($ document).keydown (e) ->
+ if window.location.hash
+ load window.location.hash
+
$ init