UNPKG

@quartic/bokehjs

Version:

Interactive, novel data visualization

514 lines (421 loc) 17.2 kB
import {Annotation, AnnotationView} from "./annotation" import {BasicTicker} from "../tickers/basic_ticker" import {BasicTickFormatter} from "../formatters/basic_tick_formatter" import {LinearColorMapper} from "../mappers/linear_color_mapper" import {LinearMapper} from "../mappers/linear_mapper" import {LogMapper} from "../mappers/log_mapper" import {Range1d} from "../ranges/range1d" import * as p from "core/properties" import * as text_util from "core/util/text" import {min, max} from "core/util/array" import {isString, isArray} from "core/util/types" SHORT_DIM = 25 LONG_DIM_MIN_SCALAR = 0.3 LONG_DIM_MAX_SCALAR = 0.8 export class ColorBarView extends AnnotationView initialize: (options) -> super(options) @_set_canvas_image() bind_bokeh_events: () -> @listenTo(@model, 'change:visible', @plot_view.request_render) @listenTo(@model.ticker, 'change', @plot_view.request_render) @listenTo(@model.formatter, 'change', @plot_view.request_render) @listenTo(@model.color_mapper, 'change', () -> @_set_canvas_image() @plot_view.request_render() ) _get_panel_offset: () -> # ColorBars draw from the top down, so set the y_panel_offset to _top x = @model.panel._left._value y = @model.panel._top._value return {x: x, y: -y} _get_size: () -> bbox = @compute_legend_dimensions() side = @model.panel.side if side == 'above' or side == 'below' return bbox.height if side == 'left' or side == 'right' return bbox.width _set_canvas_image: () -> palette = @model.color_mapper.palette if @model.orientation == 'vertical' palette = palette.slice(0).reverse() switch @model.orientation when "vertical" then [w, h] = [1, palette.length] when "horizontal" then [w, h] = [palette.length, 1] canvas = document.createElement('canvas') [canvas.width, canvas.height] = [w, h] image_ctx = canvas.getContext('2d') image_data = image_ctx.getImageData(0, 0, w, h) # We always want to draw the entire palette linearly, so we create a new # LinearColorMapper instance and map a monotonic range of values with # length = palette.length to get each palette color in order. cmap = new LinearColorMapper({palette: palette}) buf = cmap.v_map_screen([0...palette.length]) buf8 = new Uint8Array(buf) image_data.data.set(buf8) image_ctx.putImageData(image_data, 0, 0) @image = canvas compute_legend_dimensions: () -> image_dimensions = @model._computed_image_dimensions() [image_height, image_width] = [image_dimensions.height, image_dimensions.width] label_extent = @_get_label_extent() title_extent = @model._title_extent() tick_extent = @model._tick_extent() padding = @model.padding switch @model.orientation when "vertical" legend_height = image_height + title_extent + padding * 2 legend_width = image_width + tick_extent + label_extent + padding * 2 when "horizontal" legend_height = image_height + title_extent + tick_extent + label_extent + padding * 2 legend_width = image_width + padding * 2 return {height: legend_height, width: legend_width} compute_legend_location: () -> legend_dimensions = @compute_legend_dimensions() [legend_height, legend_width] = [legend_dimensions.height, legend_dimensions.width] legend_margin = @model.margin location = @model.location h_range = @plot_view.frame.h_range v_range = @plot_view.frame.v_range if isString(location) switch location when 'top_left' x = h_range.start + legend_margin y = v_range.end - legend_margin when 'top_center' x = (h_range.end + h_range.start)/2 - legend_width/2 y = v_range.end - legend_margin when 'top_right' x = h_range.end - legend_margin - legend_width y = v_range.end - legend_margin when 'center_right' x = h_range.end - legend_margin - legend_width y = (v_range.end + v_range.start)/2 + legend_height/2 when 'bottom_right' x = h_range.end - legend_margin - legend_width y = v_range.start + legend_margin + legend_height when 'bottom_center' x = (h_range.end + h_range.start)/2 - legend_width/2 y = v_range.start + legend_margin + legend_height when 'bottom_left' x = h_range.start + legend_margin y = v_range.start + legend_margin + legend_height when 'center_left' x = h_range.start + legend_margin y = (v_range.end + v_range.start)/2 + legend_height/2 when 'center' x = (h_range.end + h_range.start)/2 - legend_width/2 y = (v_range.end + v_range.start)/2 + legend_height/2 else if isArray(location) and location.length == 2 [x, y] = location sx = @plot_view.canvas.vx_to_sx(x) sy = @plot_view.canvas.vy_to_sy(y) return {sx: sx, sy: sy} render: () -> if not @model.visible return ctx = @plot_view.canvas_view.ctx ctx.save() if @model.panel? panel_offset = @_get_panel_offset() ctx.translate(panel_offset.x, panel_offset.y) frame_offset = @_get_frame_offset() ctx.translate(frame_offset.x, frame_offset.y) location = @compute_legend_location() ctx.translate(location.sx, location.sy) @_draw_bbox(ctx) image_offset = @_get_image_offset() ctx.translate(image_offset.x, image_offset.y) @_draw_image(ctx) if @model.color_mapper.low? and @model.color_mapper.high? @_draw_major_ticks(ctx) @_draw_minor_ticks(ctx) @_draw_major_labels(ctx) if @model.title @_draw_title(ctx) ctx.restore() _draw_bbox: (ctx) -> bbox = @compute_legend_dimensions() ctx.save() if @visuals.background_fill.doit @visuals.background_fill.set_value(ctx) ctx.fillRect(0, 0, bbox.width, bbox.height) if @visuals.border_line.doit @visuals.border_line.set_value(ctx) ctx.strokeRect(0, 0, bbox.width, bbox.height) ctx.restore() _draw_image: (ctx) -> image = @model._computed_image_dimensions() ctx.save() ctx.setImageSmoothingEnabled(false) ctx.globalAlpha = @model.scale_alpha ctx.drawImage(@image, 0, 0, image.width, image.height) if @visuals.bar_line.doit @visuals.bar_line.set_value(ctx) ctx.strokeRect(0, 0, image.width, image.height) ctx.restore() _draw_major_ticks: (ctx) -> if not @visuals.major_tick_line.doit return [nx, ny] = @model._normals() image = @model._computed_image_dimensions() [x_offset, y_offset] = [image.width * nx, image.height * ny] [sx, sy] = @model._tick_coordinates().major tin = @model.major_tick_in tout = @model.major_tick_out ctx.save() ctx.translate(x_offset, y_offset) @visuals.major_tick_line.set_value(ctx) for i in [0...sx.length] ctx.beginPath() ctx.moveTo(Math.round(sx[i]+nx*tout), Math.round(sy[i]+ny*tout)) ctx.lineTo(Math.round(sx[i]-nx*tin), Math.round(sy[i]-ny*tin)) ctx.stroke() ctx.restore() _draw_minor_ticks: (ctx) -> if not @visuals.minor_tick_line.doit return [nx, ny] = @model._normals() image = @model._computed_image_dimensions() [x_offset, y_offset] = [image.width * nx, image.height * ny] [sx, sy] = @model._tick_coordinates().minor tin = @model.minor_tick_in tout = @model.minor_tick_out ctx.save() ctx.translate(x_offset, y_offset) @visuals.minor_tick_line.set_value(ctx) for i in [0...sx.length] ctx.beginPath() ctx.moveTo(Math.round(sx[i]+nx*tout), Math.round(sy[i]+ny*tout)) ctx.lineTo(Math.round(sx[i]-nx*tin), Math.round(sy[i]-ny*tin)) ctx.stroke() ctx.restore() _draw_major_labels: (ctx) -> if not @visuals.major_label_text.doit return [nx, ny] = @model._normals() image = @model._computed_image_dimensions() [x_offset, y_offset] = [image.width * nx, image.height * ny] standoff = (@model.label_standoff + @model._tick_extent()) [x_standoff, y_standoff] = [standoff*nx, standoff*ny] [sx, sy] = @model._tick_coordinates().major labels = @model._tick_coordinates().major_labels # note: passing null as cross_loc probably means MercatorTickFormatters, etc # will not function properly in conjunction with colorbars formatted_labels = @model.formatter.doFormat(labels, null) @visuals.major_label_text.set_value(ctx) ctx.save() ctx.translate(x_offset + x_standoff, y_offset + y_standoff) for i in [0...sx.length] ctx.fillText(formatted_labels[i], Math.round(sx[i]+nx*@model.label_standoff), Math.round(sy[i]+ny*@model.label_standoff)) ctx.restore() _draw_title: (ctx) -> if not @visuals.title_text.doit return ctx.save() @visuals.title_text.set_value(ctx) ctx.fillText(@model.title, 0, -@model.title_standoff) ctx.restore() _get_label_extent: () -> if @model.color_mapper.low? and @model.color_mapper.high? ctx = @plot_view.canvas_view.ctx ctx.save() @visuals.major_label_text.set_value(ctx) switch @model.orientation when "vertical" formatted_labels = @model.formatter.doFormat(@model._tick_coordinates().major_labels) label_extent = max((ctx.measureText(label.toString()).width for label in formatted_labels)) when "horizontal" label_extent = text_util.get_text_height(@visuals.major_label_text.font_value()).height label_extent += @model.label_standoff ctx.restore() else label_extent = 0 return label_extent _get_frame_offset: () -> # Return frame offset relative to plot so that colorbar can align with frame [xoff, yoff] = [0, 0] panel = @model.panel frame = @plot_view.frame switch panel.side when "left", "right" then yoff = Math.abs(panel.top - frame.top) when "above", "below" then xoff = Math.abs(frame.left) return {x: xoff, y: yoff} _get_image_offset: () -> # Returns image offset relative to legend bounding box x = @model.padding y = @model.padding + @model._title_extent() return {x: x, y: y} export class ColorBar extends Annotation default_view: ColorBarView type: 'ColorBar' @mixins [ 'text:major_label_', 'text:title_', 'line:major_tick_', 'line:minor_tick_', 'line:border_', 'line:bar_', 'fill:background_', ] @define { location: [ p.Any, 'top_right' ] orientation: [ p.Orientation, 'vertical' ] title: [ p.String, ] title_standoff: [ p.Number, 2 ] height: [ p.Any, 'auto' ] width: [ p.Any, 'auto' ] scale_alpha: [ p.Number, 1.0 ] ticker: [ p.Instance, () -> new BasicTicker() ] formatter: [ p.Instance, () -> new BasicTickFormatter() ] color_mapper: [ p.Instance ] label_standoff: [ p.Number, 5 ] margin: [ p.Number, 30 ] padding: [ p.Number, 10 ] major_tick_in: [ p.Number, 5 ] major_tick_out: [ p.Number, 0 ] minor_tick_in: [ p.Number, 0 ] minor_tick_out: [ p.Number, 0 ] } @override { background_fill_color: "#ffffff" background_fill_alpha: 0.95 bar_line_color: null border_line_color: null major_label_text_align: "center" major_label_text_baseline: "middle" major_label_text_font_size: "8pt" major_tick_line_color: "#ffffff" minor_tick_line_color: null title_text_font_size: "10pt" title_text_font_style: "italic" } initialize: (attrs, options) -> super(attrs, options) _normals: () -> if @orientation == 'vertical' [i, j] = [1, 0] else [i, j] = [0, 1] return [i, j] _title_extent: () -> font_value = @title_text_font + " " + @title_text_font_size + " " + @title_text_font_style title_extent = if @title then text_util.get_text_height(font_value).height + @title_standoff else 0 return title_extent _tick_extent: () -> if @color_mapper.low? and @color_mapper.high? tick_extent = max([@major_tick_out, @minor_tick_out]) else tick_extent = 0 return tick_extent _computed_image_dimensions: () -> ### Heuristics to determine ColorBar image dimensions if set to "auto" Note: Returns the height/width values for the ColorBar's scale image, not the dimensions of the entire ColorBar. If the short dimension (the width of a vertical bar or height of a horizontal bar) is set to "auto", the resulting dimension will be set to 25 px. For a ColorBar in a side panel with the long dimension (the height of a vertical bar or width of a horizontal bar) set to "auto", the resulting dimension will be as long as the adjacent frame edge, so that the bar "fits" to the plot. For a ColorBar in the plot frame with the long dimension set to "auto", the resulting dimension will be the greater of: * The length of the color palette * 25px * The parallel frame dimension * 0.30 (i.e the frame height for a vertical ColorBar) But not greater than: * The parallel frame dimension * 0.80 ### frame_height = @plot.plot_canvas.frame.height frame_width = @plot.plot_canvas.frame.width title_extent = @_title_extent() switch @orientation when "vertical" if @height == 'auto' if @panel? height = frame_height - 2 * @padding - title_extent else height = max([@color_mapper.palette.length * SHORT_DIM, frame_height * LONG_DIM_MIN_SCALAR]) height = min([height, frame_height * LONG_DIM_MAX_SCALAR - 2 * @padding - title_extent]) else height = @height width = if @width == 'auto' then SHORT_DIM else @width when "horizontal" height = if @height == 'auto' then SHORT_DIM else @height if @width == 'auto' if @panel? width = frame_width - 2 * @padding else width = max([@color_mapper.palette.length * SHORT_DIM, frame_width * LONG_DIM_MIN_SCALAR]) width = min([width, frame_width * LONG_DIM_MAX_SCALAR - 2 * @padding]) else width = @width return {"height": height, "width": width} _tick_coordinate_mapper: (scale_length) -> ### Creates and returns a mapper instance that maps the `color_mapper` range (low to high) to a screen space range equal to the length of the ColorBar's scale image. The mapper is used to calculate the tick coordinates in screen coordinates for plotting purposes. Note: the type of color_mapper has to match the type of mapper (i.e. a LinearColorMapper will require a corresponding LinearMapper instance). ### mapping = { 'source_range': new Range1d({ start: @color_mapper.low end: @color_mapper.high }) 'target_range': new Range1d({ start: 0 end: scale_length}) } switch @color_mapper.type when "LinearColorMapper" then mapper = new LinearMapper(mapping) when "LogColorMapper" then mapper = new LogMapper(mapping) return mapper _tick_coordinates: () -> image_dimensions = @_computed_image_dimensions() switch @orientation when "vertical" then scale_length = image_dimensions.height when "horizontal" then scale_length = image_dimensions.width mapper = @_tick_coordinate_mapper(scale_length) [i, j] = @_normals() [start, end] = [@color_mapper.low, @color_mapper.high] # note: passing null as cross_loc probably means MercatorTickers, etc # will not function properly in conjunction with colorbars ticks = @ticker.get_ticks(start, end, null, null, @ticker.desired_num_ticks) majors = ticks.major minors = ticks.minor major_coords = [[], []] minor_coords = [[], []] for ii in [0...majors.length] if majors[ii] < start or majors[ii] > end continue major_coords[i].push(majors[ii]) major_coords[j].push(0) for ii in [0...minors.length] if minors[ii] < start or minors[ii] > end continue minor_coords[i].push(minors[ii]) minor_coords[j].push(0) major_labels = major_coords[i].slice(0) # make deep copy major_coords[i] = mapper.v_map_to_target(major_coords[i]) minor_coords[i] = mapper.v_map_to_target(minor_coords[i]) # Because we want the scale to be reversed if @orientation == 'vertical' major_coords[i] = new Float64Array((scale_length - coord for coord in major_coords[i])) minor_coords[i] = new Float64Array((scale_length - coord for coord in minor_coords[i])) return { "major": major_coords "minor": minor_coords "major_labels": major_labels }