@quartic/bokehjs
Version:
Interactive, novel data visualization
840 lines (695 loc) • 29.1 kB
text/coffeescript
import {Canvas} from "../canvas/canvas"
import {CartesianFrame} from "../canvas/cartesian_frame"
import {DataRange1d} from "../ranges/data_range1d"
import {GlyphRenderer} from "../renderers/glyph_renderer"
import {LayoutDOM} from "../layouts/layout_dom"
import {build_views} from "core/build_views"
import {UIEvents} from "core/ui_events"
import {LODStart, LODEnd} from "core/bokeh_events"
import {LayoutCanvas} from "core/layout/layout_canvas"
import {Visuals} from "core/visuals"
import {BokehView} from "core/bokeh_view"
import {EQ, GE} from "core/layout/solver"
import {logger} from "core/logging"
import * as enums from "core/enums"
import * as p from "core/properties"
import {throttle} from "core/util/throttle"
import {isStrictNaN} from "core/util/types"
import {difference, sortBy} from "core/util/array"
import {extend, values, isEmpty} from "core/util/object"
import {defer} from "core/util/callback"
import {update_constraints as update_panel_constraints} from "core/layout/side_panel"
# Notes on WebGL support:
# Glyps can be rendered into the original 2D canvas, or in a (hidden)
# webgl canvas that we create below. In this way, the rest of bokehjs
# can keep working as it is, and we can incrementally update glyphs to
# make them use GL.
#
# When the author or user wants to, we try to create a webgl canvas,
# which is saved on the ctx object that gets passed around during drawing.
# The presence (and not-being-false) of the ctx.glcanvas attribute is the
# marker that we use throughout that determines whether we have gl support.
global_glcanvas = null
export class PlotCanvasView extends BokehView
className: "bk-plot-wrapper"
state: { history: [], index: -1 }
view_options: () -> extend({plot_view: @}, @options)
pause: () ->
@is_paused = true
unpause: () ->
@is_paused = false
@request_render()
request_render: () =>
if not @is_paused
@throttled_render()
return
remove: () =>
super()
# When this view is removed, also remove all of the tools.
for id, tool_view of @tool_views
tool_view.remove()
initialize: (options) ->
super(options)
@pause()
@lod_started = false
@visuals = new Visuals(@model.plot)
@_initial_state_info = {
range: null # set later by set_initial_range()
selection: {} # XXX: initial selection?
dimensions: {
width: @model.canvas.width
height: @model.canvas.height
}
}
# compat, to be removed
@frame = @model.frame
@x_range = @frame.x_ranges['default']
@y_range = @frame.y_ranges['default']
@xmapper = @frame.x_mappers['default']
@ymapper = @frame.y_mappers['default']
@canvas = @model.canvas
@canvas_view = new @canvas.default_view({'model': @canvas})
@el.appendChild(@canvas_view.el)
@canvas_view.render(true)
# If requested, try enabling webgl
if @model.plot.webgl
@init_webgl()
@throttled_render = throttle(@render, 15) # TODO (bev) configurable
# Keep track of which plots of the canvas are not yet rendered
if not @model.document._unrendered_plots?
@model.document._unrendered_plots = {} # poor man's set
@model.document._unrendered_plots[@id] = true
@ui_event_bus = new UIEvents(@, @model.toolbar, @canvas_view.el, @model.plot)
@levels = {}
for level in enums.RenderLevel
@levels[level] = {}
@renderer_views = {}
@tool_views = {}
@build_levels()
@build_tools()
@bind_bokeh_events()
@update_dataranges()
@unpause()
logger.debug("PlotView initialized")
return this
get_canvas_element: () ->
return @canvas_view.ctx.canvas
set_cursor: (cursor="default") ->
@canvas_view.el.style.cursor = cursor
@getters {
canvas_overlays: () -> @el.querySelector('.bk-canvas-overlays')
}
init_webgl: () ->
ctx = @canvas_view.ctx
# We use a global invisible canvas and gl context. By having a global context,
# we avoid the limitation of max 16 contexts that most browsers have.
glcanvas = global_glcanvas
if not glcanvas?
global_glcanvas = glcanvas = document.createElement('canvas')
opts = {'premultipliedAlpha': true} # premultipliedAlpha is true by default
glcanvas.gl = glcanvas.getContext("webgl", opts) || glcanvas.getContext("experimental-webgl", opts)
# If WebGL is available, we store a reference to the gl canvas on
# the ctx object, because that's what gets passed everywhere.
if glcanvas.gl?
ctx.glcanvas = glcanvas
else
logger.warn('WebGL is not supported, falling back to 2D canvas.')
prepare_webgl: (ratio, frame_box) ->
# Prepare WebGL for a drawing pass
ctx = @canvas_view.ctx
canvas = @canvas_view.get_canvas_element()
if ctx.glcanvas
# Sync canvas size
ctx.glcanvas.width = canvas.width
ctx.glcanvas.height = canvas.height
# Prepare GL for drawing
gl = ctx.glcanvas.gl
gl.viewport(0, 0, ctx.glcanvas.width, ctx.glcanvas.height)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT || gl.DEPTH_BUFFER_BIT)
# Clipping
gl.enable(gl.SCISSOR_TEST)
flipped_top = ctx.glcanvas.height - ratio * (frame_box[1] + frame_box[3])
gl.scissor(ratio * frame_box[0], flipped_top, ratio * frame_box[2], ratio * frame_box[3])
# Setup blending
gl.enable(gl.BLEND)
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE_MINUS_DST_ALPHA, gl.ONE) # premultipliedAlpha == true
#gl.blendFuncSeparate(gl.ONE_MINUS_DST_ALPHA, gl.DST_ALPHA, gl.ONE_MINUS_DST_ALPHA, gl.ONE) # Without premultipliedAlpha == false
blit_webgl: (ratio) ->
# This should be called when the ctx has no state except the HIDPI transform
ctx = @canvas_view.ctx
if ctx.glcanvas
# Blit gl canvas into the 2D canvas. To do 1-on-1 blitting, we need
# to remove the hidpi transform, then blit, then restore.
# ctx.globalCompositeOperation = "source-over" -> OK; is the default
logger.debug('drawing with WebGL')
ctx.restore()
ctx.drawImage(ctx.glcanvas, 0, 0)
# Set back hidpi transform
ctx.save()
ctx.scale(ratio, ratio)
ctx.translate(0.5, 0.5)
update_dataranges: () ->
# Update any DataRange1ds here
frame = @model.frame
bounds = {}
log_bounds = {}
calculate_log_bounds = false
for r in values(frame.x_ranges).concat(values(frame.y_ranges))
if r instanceof DataRange1d
if r.mapper_hint == "log"
calculate_log_bounds = true
for k, v of @renderer_views
bds = v.glyph?.bounds?()
if bds?
bounds[k] = bds
if calculate_log_bounds
log_bds = v.glyph?.log_bounds?()
if log_bds?
log_bounds[k] = log_bds
follow_enabled = false
has_bounds = false
for xr in values(frame.x_ranges)
if xr instanceof DataRange1d
bounds_to_use = if xr.mapper_hint == "log" then log_bounds else bounds
xr.update(bounds_to_use, 0, @model.id)
if xr.follow
follow_enabled = true
has_bounds = true if xr.bounds?
for yr in values(frame.y_ranges)
if yr instanceof DataRange1d
bounds_to_use = if yr.mapper_hint == "log" then log_bounds else bounds
yr.update(bounds_to_use, 1, @model.id)
if yr.follow
follow_enabled = true
has_bounds = true if yr.bounds?
if follow_enabled and has_bounds
logger.warn('Follow enabled so bounds are unset.')
for xr in values(frame.x_ranges)
xr.bounds = null
for yr in values(frame.y_ranges)
yr.bounds = null
@range_update_timestamp = Date.now()
map_to_screen: (x, y, x_name='default', y_name='default') ->
@frame.map_to_screen(x, y, @canvas, x_name, y_name)
push_state: (type, info) ->
prev_info = @state.history[@state.index]?.info or {}
info = extend({}, @_initial_state_info, prev_info, info)
@state.history.slice(0, @state.index + 1)
@state.history.push({type: type, info: info})
@state.index = @state.history.length - 1
@trigger("state_changed")
clear_state: () ->
@state = {history: [], index: -1}
@trigger("state_changed")
can_undo: () ->
@state.index >= 0
can_redo: () ->
@state.index < @state.history.length - 1
undo: () ->
if @can_undo()
@state.index -= 1
@_do_state_change(@state.index)
@trigger("state_changed")
redo: () ->
if @can_redo()
@state.index += 1
@_do_state_change(@state.index)
@trigger("state_changed")
_do_state_change: (index) ->
info = @state.history[index]?.info or @_initial_state_info
if info.range?
@update_range(info.range)
if info.selection?
@update_selection(info.selection)
if info.dimensions?
@canvas_view.set_dims([info.dimensions.width, info.dimensions.height])
reset_dimensions: () ->
@update_dimensions(@canvas.initial_width, @canvas.initial_height)
update_dimensions: (width, height) ->
@pause()
@model.plot.width = width
@model.plot.height = height
@model.document.resize()
@unpause()
get_selection: () ->
selection = []
for renderer in @model.plot.renderers
if renderer instanceof GlyphRenderer
selected = renderer.data_source.selected
selection[renderer.id] = selected
selection
update_selection: (selection) ->
for renderer in @model.plot.renderers
if renderer not instanceof GlyphRenderer
continue
ds = renderer.data_source
if selection?
if renderer.id in selection
ds.selected = selection[renderer.id]
else
ds.selection_manager.clear()
reset_selection: () ->
@update_selection(null)
_update_ranges_together: (range_info_iter) ->
# Get weight needed to scale the diff of the range to honor interval limits
weight = 1.0
for [rng, range_info] in range_info_iter
weight = Math.min(weight, @_get_weight_to_constrain_interval(rng, range_info))
# Apply shared weight to all ranges
if weight < 1
for [rng, range_info] in range_info_iter
range_info['start'] = weight * range_info['start'] + (1-weight) * rng.start
range_info['end'] = weight * range_info['end'] + (1-weight) * rng.end
_update_ranges_individually: (range_info_iter, is_panning, is_scrolling) ->
hit_bound = false
for [rng, range_info] in range_info_iter
# Is this a reversed range?
reversed = (rng.start > rng.end)
# Limit range interval first. Note that for scroll events,
# the interval has already been limited for all ranges simultaneously
if not is_scrolling
weight = @_get_weight_to_constrain_interval(rng, range_info)
if weight < 1
range_info['start'] = weight * range_info['start'] + (1-weight) * rng.start
range_info['end'] = weight * range_info['end'] + (1-weight) * rng.end
# Prevent range from going outside limits
# Also ensure that range keeps the same delta when panning/scrolling
if rng.bounds?
min = rng.bounds[0]
max = rng.bounds[1]
new_interval = Math.abs(range_info['end'] - range_info['start'])
if reversed
if min?
if min >= range_info['end']
hit_bound = true
range_info['end'] = min
if is_panning? or is_scrolling?
range_info['start'] = min + new_interval
if max?
if max <= range_info['start']
hit_bound = true
range_info['start'] = max
if is_panning? or is_scrolling?
range_info['end'] = max - new_interval
else
if min?
if min >= range_info['start']
hit_bound = true
range_info['start'] = min
if is_panning? or is_scrolling?
range_info['end'] = min + new_interval
if max?
if max <= range_info['end']
hit_bound = true
range_info['end'] = max
if is_panning? or is_scrolling?
range_info['start'] = max - new_interval
# Cancel the event when hitting a bound while scrolling. This ensures that
# the scroll-zoom tool maintains its focus position. Disabling the next
# two lines would result in a more "gliding" behavior, allowing one to
# zoom out more smoothly, at the cost of losing the focus position.
if is_scrolling and hit_bound
return
for [rng, range_info] in range_info_iter
rng.have_updated_interactively = true
if rng.start != range_info['start'] or rng.end != range_info['end']
rng.setv(range_info)
_get_weight_to_constrain_interval: (rng, range_info) ->
# Get the weight by which a range-update can be applied
# to still honor the interval limits (including the implicit
# max interval imposed by the bounds)
min_interval = rng.min_interval
max_interval = rng.max_interval
weight = 1.0
# Express bounds as a max_interval. By doing this, the application of
# bounds and interval limits can be applied independent from each-other.
if rng.bounds?
[min, max] = rng.bounds
if min? and max?
max_interval2 = Math.abs(max - min)
max_interval = if max_interval? then Math.min(max_interval, max_interval2) else max_interval2
if min_interval? || max_interval?
old_interval = Math.abs(rng.end - rng.start)
new_interval = Math.abs(range_info['end'] - range_info['start'])
if min_interval > 0 and new_interval < min_interval
weight = (old_interval - min_interval) / (old_interval - new_interval)
if max_interval > 0 and new_interval > max_interval
weight = (max_interval - old_interval) / (new_interval - old_interval)
weight = Math.max(0.0, Math.min(1.0, weight))
return weight
update_range: (range_info, is_panning, is_scrolling) ->
@pause
if not range_info?
for name, rng of @frame.x_ranges
rng.reset()
for name, rng of @frame.y_ranges
rng.reset()
@update_dataranges()
else
range_info_iter = []
for name, rng of @frame.x_ranges
range_info_iter.push([rng, range_info.xrs[name]])
for name, rng of @frame.y_ranges
range_info_iter.push([rng, range_info.yrs[name]])
if is_scrolling
@_update_ranges_together(range_info_iter) # apply interval bounds while keeping aspect
@_update_ranges_individually(range_info_iter, is_panning, is_scrolling)
@unpause()
reset_range: () ->
@update_range(null)
build_levels: () ->
renderer_models = @model.plot.all_renderers
# should only bind events on NEW views
old_renderers = Object.keys(@renderer_views)
new_renderer_views = build_views(@renderer_views, renderer_models, @view_options())
renderers_to_remove = difference(old_renderers, (model.id for model in renderer_models))
for id_ in renderers_to_remove
delete @levels.glyph[id_]
for view in new_renderer_views
@levels[view.model.level][view.model.id] = view
view.bind_bokeh_events()
return @
get_renderer_views: () ->
(@levels[r.level][r.id] for r in @model.plot.renderers)
build_tools: () ->
tool_models = @model.plot.toolbar.tools
new_tool_views = build_views(@tool_views, tool_models, @view_options())
for tool_view in new_tool_views
tool_view.bind_bokeh_events()
@ui_event_bus.register_tool(tool_view)
bind_bokeh_events: () ->
for name, rng of @model.frame.x_ranges
@listenTo(rng, 'change', @request_render)
for name, rng of @model.frame.y_ranges
@listenTo(rng, 'change', @request_render)
@listenTo(@model.plot, 'change:renderers', () => @build_levels())
@listenTo(@model.plot.toolbar, 'change:tools', () => @build_levels(); @build_tools())
@listenTo(@model.plot, 'change', @request_render)
@listenTo(@model.plot, 'destroy', () => @remove())
@listenTo(@model.plot.document.solver(), 'layout_update', () => @request_render())
@listenTo(@model.plot.document.solver(), 'layout_update', () =>
@model.plot.setv({
inner_width: Math.round(@frame.width)
inner_height: Math.round(@frame.height)
layout_width: Math.round(@canvas.width)
layout_height: Math.round(@canvas.height)
})
)
@listenTo(@model.plot.document.solver(), 'resize', () => @resize())
@listenTo(@canvas, 'change:pixel_ratio', () => @request_render())
set_initial_range : () ->
# check for good values for ranges before setting initial range
good_vals = true
xrs = {}
for name, rng of @frame.x_ranges
if (not rng.start? or not rng.end? or isStrictNaN(rng.start + rng.end))
good_vals = false
break
xrs[name] = { start: rng.start, end: rng.end }
if good_vals
yrs = {}
for name, rng of @frame.y_ranges
if (not rng.start? or not rng.end? or isStrictNaN(rng.start + rng.end))
good_vals = false
break
yrs[name] = { start: rng.start, end: rng.end }
if good_vals
@_initial_state_info.range = @initial_range_info = {
xrs: xrs
yrs: yrs
}
logger.debug("initial ranges set")
else
logger.warn('could not set initial ranges')
render: (force_canvas=false) ->
logger.trace("PlotCanvas.render(force_canvas=#{force_canvas}) for #{@model.id}")
if not @model.document?
return
if Date.now() - @interactive_timestamp < @model.plot.lod_interval
if not @lod_started
@model.plot.trigger_event(new LODStart({}))
@lod_started = true
@interactive = true
lod_timeout = @model.plot.lod_timeout
setTimeout(() =>
if @interactive and (Date.now() - @interactive_timestamp) > lod_timeout
@interactive = false
@request_render()
, lod_timeout)
else
@interactive = false
if @lod_started
@model.plot.trigger_event(new LODEnd({}))
@lod_started = false
for k, v of @renderer_views
if not @range_update_timestamp? or v.set_data_timestamp > @range_update_timestamp
@update_dataranges()
break
# AK: seems weird to me that this is here, but get solver errors if I remove it
@update_constraints()
# TODO (bev) OK this sucks, but the event from the solver update doesn't
# reach the frame in time (sometimes) so force an update here for now
@model.frame._update_mappers()
ctx = @canvas_view.ctx
ctx.pixel_ratio = ratio = @canvas_view.pixel_ratio # Also store on cts for WebGL
# Set hidpi-transform
ctx.save() # Save default state, do *after* getting ratio, cause setting canvas.width resets transforms
ctx.scale(ratio, ratio)
ctx.translate(0.5, 0.5)
frame_box = [
@canvas.vx_to_sx(@frame.left),
@canvas.vy_to_sy(@frame.top),
@frame.width,
@frame.height,
]
@_map_hook(ctx, frame_box)
@_paint_empty(ctx, frame_box)
@prepare_webgl(ratio, frame_box)
ctx.save()
if @visuals.outline_line.doit
@visuals.outline_line.set_value(ctx)
ctx.strokeRect.apply(ctx, frame_box)
ctx.restore()
@_render_levels(ctx, ['image', 'underlay', 'glyph'], frame_box)
@blit_webgl(ratio)
@_render_levels(ctx, ['annotation'], frame_box)
@_render_levels(ctx, ['overlay'])
if not @initial_range_info?
@set_initial_range()
ctx.restore() # Restore to default state
# Invoke a resize on the document the first time that all plots of that
# document are rendered. For some reason, the layout solver only works well
# after the plots have been rendered. See #4401.
if @model.document._unrendered_plots?
delete @model.document._unrendered_plots[@id]
if isEmpty(@model.document._unrendered_plots)
@model.document._unrendered_plots = null
defer(@model.document.resize.bind(@model.document))
event = new Event("bokeh:rendered", {detail: @})
window.dispatchEvent(event)
resize: () ->
# Set the plot and canvas to the current model's size
# This gets called upon solver resize events
width = @model._width._value
height = @model._height._value
@canvas_view.set_dims([width, height], true) # this indirectly calls @request_render
# Prepare the canvas size, taking HIDPI into account. Note that this may cause
# a resize of the canvas, which means that any previous calls to ctx.save() may be undone.
@canvas_view.prepare_canvas()
@update_constraints()
# This allows the plot canvas to be positioned around the toolbar
@el.style.position = 'absolute'
@el.style.left = "#{@model._dom_left._value}px"
@el.style.top = "#{@model._dom_top._value}px"
@el.style.width = "#{@model._width._value}px"
@el.style.height = "#{@model._height._value}px"
update_constraints: () ->
s = @model.document.solver()
# Note: -1 to effectively dilate the canvas by 1px
s.suggest_value(@frame._width, @canvas.width - 1)
s.suggest_value(@frame._height, @canvas.height - 1)
for model_id, view of @renderer_views
if view.model.panel?
update_panel_constraints(view)
s.update_variables(false)
_render_levels: (ctx, levels, clip_region) ->
ctx.save()
if clip_region?
ctx.beginPath()
ctx.rect.apply(ctx, clip_region)
ctx.clip()
ctx.beginPath()
indices = {}
for renderer, i in @model.plot.renderers
indices[renderer.id] = i
sortKey = (renderer_view) -> indices[renderer_view.model.id]
for level in levels
renderer_views = sortBy(values(@levels[level]), sortKey)
for renderer_view in renderer_views
renderer_view.render()
ctx.restore()
_map_hook: (ctx, frame_box) ->
_paint_empty: (ctx, frame_box) ->
ctx.clearRect(0, 0, @canvas_view.model.width, @canvas_view.model.height)
if @visuals.border_fill.doit
@visuals.border_fill.set_value(ctx)
ctx.fillRect(0, 0, @canvas_view.model.width, @canvas_view.model.height)
ctx.clearRect(frame_box...)
if @visuals.background_fill.doit
@visuals.background_fill.set_value(ctx)
ctx.fillRect(frame_box...)
save: (name) ->
canvas = @get_canvas_element()
if canvas.msToBlob?
blob = canvas.msToBlob()
window.navigator.msSaveBlob(blob, name)
else
link = document.createElement('a')
link.href = canvas.toDataURL('image/png')
link.download = name
link.target = "_blank"
link.dispatchEvent(new MouseEvent('click'))
export class PlotCanvas extends LayoutDOM
type: 'PlotCanvas'
default_view: PlotCanvasView
initialize: (attrs, options) ->
super(attrs, options)
@canvas = new Canvas({
map: @use_map ? false
initial_width: @plot.plot_width,
initial_height: @plot.plot_height,
use_hidpi: @plot.hidpi
})
@frame = new CartesianFrame({
x_range: @plot.x_range,
extra_x_ranges: @plot.extra_x_ranges,
x_mapper_type: @plot.x_mapper_type,
y_range: @plot.y_range,
extra_y_ranges: @plot.extra_y_ranges,
y_mapper_type: @plot.y_mapper_type,
})
@above_panel = new LayoutCanvas()
@below_panel = new LayoutCanvas()
@left_panel = new LayoutCanvas()
@right_panel = new LayoutCanvas()
logger.debug("PlotCanvas initialized")
add_renderer_to_canvas_side: (renderer, side) ->
# Calling this method after a plot has been initialized may (will?)
# fail because the new constraints from the panel may
# not be added to the solver.
#
# TODO (bird): We could make it more formal that in order for
# a renderer to be available as an off-center item, it needs an add_panel
# method. Currently axis and annotation have these.
#
# TODO (bird): Should we actually just throw an error if you try
# to call this for a center renderer to help with clarity.
if side != 'center'
renderer.add_panel(side)
_doc_attached: () ->
@canvas.attach_document(@document)
@frame.attach_document(@document)
@above_panel.attach_document(@document)
@below_panel.attach_document(@document)
@left_panel.attach_document(@document)
@right_panel.attach_document(@document)
logger.debug("PlotCanvas attached to document")
@override {
# We should find a way to enforce this
sizing_mode: 'stretch_both'
}
@internal {
plot: [ p.Instance ]
toolbar: [ p.Instance ]
canvas: [ p.Instance ]
frame: [ p.Instance ]
}
get_layoutable_children: () ->
children = [
@above_panel, @below_panel,
@left_panel, @right_panel,
@canvas, @frame,
]
collect_panels = (layout_renderers) ->
for r in layout_renderers
if r.panel?
children.push(r.panel)
collect_panels(@plot.above)
collect_panels(@plot.below)
collect_panels(@plot.left)
collect_panels(@plot.right)
return children
get_edit_variables: () ->
edit_variables = []
# Go down the children to pick up any more constraints
for child in @get_layoutable_children()
edit_variables = edit_variables.concat(child.get_edit_variables())
return edit_variables
get_constraints: () ->
constraints = super()
constraints = constraints.concat(@_get_constant_constraints())
constraints = constraints.concat(@_get_side_constraints())
# Go down the children to pick up any more constraints
for child in @get_layoutable_children()
constraints = constraints.concat(child.get_constraints())
return constraints
_get_constant_constraints: () ->
min_border_top = @plot.min_border_top
min_border_bottom = @plot.min_border_bottom
min_border_left = @plot.min_border_left
min_border_right = @plot.min_border_right
# Create the constraints that always apply for a plot
constraints = []
# Set the border constraints
constraints.push(GE( @above_panel._height, -min_border_top ))
constraints.push(GE( @below_panel._height, -min_border_bottom ))
constraints.push(GE( @left_panel._width, -min_border_left ))
constraints.push(GE( @right_panel._width, -min_border_right ))
# Set panel top and bottom related to canvas and frame
constraints.push(EQ( @above_panel._top, [-1, @canvas._top] ))
constraints.push(EQ( @above_panel._bottom, [-1, @frame._top] ))
constraints.push(EQ( @below_panel._bottom, [-1, @canvas._bottom] ))
constraints.push(EQ( @below_panel._top, [-1, @frame._bottom] ))
constraints.push(EQ( @left_panel._left, [-1, @canvas._left] ))
constraints.push(EQ( @left_panel._right, [-1, @frame._left] ))
constraints.push(EQ( @right_panel._right, [-1, @canvas._right] ))
constraints.push(EQ( @right_panel._left, [-1, @frame._right] ))
# Plot sides align
constraints.push(EQ( @above_panel._height, [-1, @_top] ))
constraints.push(EQ( @above_panel._height, [-1, @canvas._top], @frame._top ))
constraints.push(EQ( @below_panel._height, [-1, @_height], @_bottom ))
constraints.push(EQ( @below_panel._height, [-1, @frame._bottom] ))
constraints.push(EQ( @left_panel._width, [-1, @_left] ))
constraints.push(EQ( @left_panel._width, [-1, @frame._left] ))
constraints.push(EQ( @right_panel._width, [-1, @_width], @_right ))
constraints.push(EQ( @right_panel._width, [-1, @canvas._right], @frame._right ))
return constraints
_get_side_constraints: () ->
constraints = []
sides = [['above', @plot.above], ['below', @plot.below],
['left', @plot.left], ['right', @plot.right]]
for [side, layout_renderers] in sides
last = @frame
for r in layout_renderers
# Stack together the renderers
constraint = switch side
when "above" then EQ(last.panel._top, [-1, r.panel._bottom])
when "below" then EQ(last.panel._bottom, [-1, r.panel._top])
when "left" then EQ(last.panel._left, [-1, r.panel._right])
when "right" then EQ(last.panel._right, [-1, r.panel._left])
constraints.push(constraint)
last = r
if layout_renderers.length != 0
# Set panel extent to match the side renderers (e.g. axes)
constraint = switch side
when "above" then EQ(last.panel._top, [-1, @above_panel._top])
when "below" then EQ(last.panel._bottom, [-1, @below_panel._bottom])
when "left" then EQ(last.panel._left, [-1, @left_panel._left])
when "right" then EQ(last.panel._right, [-1, @right_panel._right])
constraints.push(constraint)
return constraints
# TODO: This is less than awesome - this is here purely for tests to pass. Need to
# find a better way, but this was expedient for now.
plot_canvas: () ->
return @