require_dependency 'carto/api/infowindow_migrator' require_dependency 'carto/table_utils' module Carto module Api class LayerPresenter include InfowindowMigrator include Carto::TableUtils PUBLIC_VALUES = %W{ options kind infowindow tooltip id order } # CSS is not stored by default, only when sent by frontend, # so this is returned whenever a layer that needs CSS but has none is requestesd EMPTY_CSS = '#dummy{}' TORQUE_ATTRS = %w( table_name user_name property blendmode resolution countby torque-duration torque-steps torque-blend-mode query tile_style named_map visible ) INFOWINDOW_KEYS = %w( fields template_name template alternative_names width maxHeight ) # Options: # - viewer_user # - user: owner user # - with_style_properties: request style_properties generation if needed or not. Default: false. def initialize(layer, options = {}, configuration = {}, decoration_data = {}) @layer = layer @options = options @configuration = configuration @decoration_data = decoration_data @viewer_user = options.fetch(:viewer_user, nil) @owner_user = options.fetch(:user, nil) @with_style_properties = options.fetch(:with_style_properties, false) end def to_poro(migrate_builder_infowindows: false) poro = base_poro(@layer) if migrate_builder_infowindows if poro['infowindow'].present? poro['infowindow'] = migrate_builder_infowindow(poro['infowindow']) end if poro['tooltip'].present? poro['tooltip'] = migrate_builder_infowindow(poro['tooltip'], mustache_dir: 'tooltips') end end poro end def to_json public_values(@layer).to_json end def to_embed_poro { id: @layer.id, options: @layer.options.select { |k| ['tile_style', 'style_version'].include?(k) } } end def to_vizjson_v2 if base?(@layer) with_kind_as_type(base_poro(@layer)).symbolize_keys elsif torque?(@layer) as_torque else { id: @layer.id, type: 'CartoDB', infowindow: infowindow_data_v2, tooltip: tooltip_data_v2, legend: @layer.legend, order: @layer.order, visible: public_values(@layer).symbolize_keys[:options]['visible'], options: options_data_v2 } end end def to_vizjson_v1 return base_poro(@layer).symbolize_keys if base?(@layer) { id: @layer.id, kind: 'CartoDB', infowindow: infowindow_data_v1, order: @layer.order, options: options_data_v1 } end private def viewer_is_owner? return (@owner_user.id == @viewer_user.id) if (@owner_user && @viewer_user) # This can be removed if 'user_name' support is dropped layer_opts = @layer.options.nil? ? Hash.new : @layer.options if @viewer_user && layer_opts['user_name'] && layer_opts['table_name'] @viewer_user.username == layer_opts['user_name'] else true end end # INFO: Assumes table_name needs to always be qualified, don't call if doesn't def qualify_table_name layer_opts = @layer.options.nil? ? Hash.new : @layer.options # if the table_name already have a schema don't add another one. # This case happens when you share a layer already shared with you return layer_opts['table_name'] if layer_opts['table_name'].include?('.') if @owner_user && @viewer_user @layer.qualified_table_name(@owner_user) else # TODO: Legacy support: Remove 'user_name' and use always :viewer_user and :user user_name = layer_opts['user_name'] if user_name.include?('-') "\"#{layer_opts['user_name']}\".#{safe_table_name_quoting(layer_opts['table_name'])}" else "#{layer_opts['user_name']}.#{safe_table_name_quoting(layer_opts['table_name'])}" end end end def base_poro(layer) # .merge left for backwards compatibility public_values(layer).merge('options' => layer_options) end def public_values(layer) Hash[ PUBLIC_VALUES.map { |attribute| [attribute, layer.send(attribute)] } ] end # Decorates the layer presentation with data if needed. nils on the decoration act as removing the field def decorate_with_data(source_hash, decoration_data) decoration_data.each { |key, value| source_hash[key] = value source_hash.delete_if { |k, v| v.nil? } } source_hash end def base?(layer) ['tiled', 'background', 'gmapsbase', 'wms'].include? layer.kind end def torque?(layer) layer.kind == 'torque' end def with_template(infowindow, path) # Careful with this logic: # - nil means absolutely no infowindow (e.g. a torque) # - path = nil or template filled: either pre-filled or custom infowindow, nothing to do here # - template and path not nil but template not filled: stay and fill return nil if infowindow.nil? template = infowindow['template'] return infowindow if (!template.nil? && !template.empty?) || path.nil? infowindow[:template] = File.read(path) infowindow end def layer_options layer_opts = @layer.options.nil? ? Hash.new : @layer.options if layer_opts['table_name'] && !viewer_is_owner? layer_opts['table_name'] = qualify_table_name end if @with_style_properties && (layer_opts['style_properties'].nil? || layer_opts['style_properties']['autogenerated'] == true) StylePropertiesGenerator.new(@layer).migrate(layer_opts) end layer_opts end def options_data_v1 return @layer.options if @options[:full] @layer.options.select { |key, value| public_options.include?(key.to_s) } end def options_data_v2 if @options[:full] decorate_with_data(@layer.options, @decoration_data) else sql = sql_from(@layer.options) data = { sql: wrap(sql, @layer.options), layer_name: name_for(@layer), cartocss: css_from(@layer.options), cartocss_version: @layer.options.fetch('style_version'), # Mandatory interactivity: @layer.options.fetch('interactivity') # Mandatory } data = decorate_with_data(data, @decoration_data) if @viewer_user if @layer.options['table_name'] && !viewer_is_owner? data['table_name'] = qualify_table_name end end data end end def with_kind_as_type(attributes) decorate_with_data(attributes.merge(type: attributes.delete('kind')), @decoration_data) end def as_torque api_templates_type = @options.fetch(:https_request, false) ? 'private' : 'public' layer_options = decorate_with_data( # Make torque always have a SQL query too (as vizjson v2) @layer.options.merge({ 'query' => wrap(sql_from(@layer.options), @layer.options) }), @decoration_data ) { id: @layer.id, type: 'torque', order: @layer.order, legend: @layer.legend, options: { stat_tag: @options.fetch(:visualization_id), maps_api_template: ApplicationHelper.maps_api_template(api_templates_type), sql_api_template: ApplicationHelper.sql_api_template(api_templates_type), # tiler_* is kept for backwards compatibility tiler_protocol: (@configuration[:tiler]["public"]["protocol"] rescue nil), tiler_domain: (@configuration[:tiler]["public"]["domain"] rescue nil), tiler_port: (@configuration[:tiler]["public"]["port"] rescue nil), # sql_api_* is kept for backwards compatibility sql_api_protocol: (@configuration[:sql_api]["public"]["protocol"] rescue nil), sql_api_domain: (@configuration[:sql_api]["public"]["domain"] rescue nil), sql_api_endpoint: (@configuration[:sql_api]["public"]["endpoint"] rescue nil), sql_api_port: (@configuration[:sql_api]["public"]["port"] rescue nil), layer_name: name_for(@layer), }.merge( layer_options.select { |k| TORQUE_ATTRS.include? k }) } end def infowindow_data_v1 with_template(@layer.infowindow, @layer.infowindow_template_path) rescue => e CartoDB::Logger.error(exception: e) throw e end def infowindow_data_v2 whitelisted_infowindow(with_template(@layer.infowindow, @layer.infowindow_template_path)) rescue => e CartoDB::Logger.error(exception: e) throw e end def tooltip_data_v2 whitelisted_infowindow(with_template(@layer.tooltip, @layer.tooltip_template_path)) rescue => e CartoDB::Logger.error(exception: e) throw e end def name_for(layer) layer_alias = layer.options.fetch('table_name_alias', nil) table_name = layer.options['table_name'] return table_name unless layer_alias && !layer_alias.empty? layer_alias end def sql_from(options) query = options.fetch('query', '') return default_query_for(options) if query.nil? || query.empty? query end def css_from(options) style = options.include?('tile_style') ? options['tile_style'] : nil (style.nil? || style.strip.empty?) ? EMPTY_CSS : style end def wrap(query, options) wrapper = options.fetch('query_wrapper', nil) return query if wrapper.nil? || wrapper.empty? EJS.evaluate(wrapper, sql: query) end def default_query_for(layer_options) if viewer_is_owner? "select * from #{safe_table_name_quoting(layer_options['table_name'])}" else "select * from #{qualify_table_name}" end end def public_options return @configuration if @configuration.empty? @configuration.fetch(:layer_opts).fetch('public_opts') end def whitelisted_infowindow(infowindow) infowindow.nil? ? nil : infowindow.select { |key, value| INFOWINDOW_KEYS.include?(key) || INFOWINDOW_KEYS.include?(key.to_s) } end end # Used to migrate `wizard_properties` to `style_properties` class StylePropertiesGenerator def initialize(layer) @layer = layer @wizard_properties = @layer.options['wizard_properties'] @source_type = @wizard_properties.present? ? @wizard_properties['type'] : nil end def migrate(options) return nil unless @wizard_properties.present? wpp = @wizard_properties['properties'] type = if @source_type == 'density' wpp['geometry_type'] == 'Rectangles' ? 'squares' : 'hexabins' elsif @source_type == 'torque_heat' wpp['heat-animated'] ? 'animation' : 'heatmap' else STYLE_PROPERTIES_TYPE[@source_type] end return nil unless type options['cartocss_custom'] = options['tile_style_custom'] || false options['cartocss_history'] = options['tile_style_history'] || [] options['style_properties'] = { 'autogenerated' => true, 'type' => type, 'properties' => wizard_properties_properties_to_style_properties_properties(wpp, type) } merge_into_if_present(options['style_properties']['properties'], 'aggregation', generate_aggregation(wpp)) if SOURCE_TYPES_WITH_SQL_WRAP.include?(@source_type) options['sql_wrap'] = options['query_wrapper'] end if @source_type == 'cluster' options['cartocss_custom'] = true end if type == 'animation' && @layer.widgets.where(type: 'time-series').none? create_time_series_widget(wpp) end end private STYLE_PROPERTIES_TYPE = { 'polygon' => 'simple', 'bubble' => 'simple', 'choropleth' => 'simple', 'category' => 'simple', 'torque' => 'animation', 'torque_cat' => 'animation', 'cluster' => 'simple' }.freeze SOURCE_TYPES_WITH_SQL_WRAP = ['cluster', 'density', 'torque_cat'].freeze def set_if_present(hash, key, value) # Dirty check because `false` is a valid `value` hash[key] = value if value.present? hash end def merge_into_if_present(hash, key, hash_value) hash[key] = hash_value.deep_merge!(hash[key] || {}) if hash_value.present? hash end def apply_direct_mapping(hash, original_hash, mapping) mapping.each do |source, target| set_if_present(hash, target, original_hash[source]) end hash end def apply_default_opacity(hash) if hash['fixed'] && !hash['opacity'] hash['opacity'] = 1 end hash end PROPERTIES_DIRECT_MAPPING = { "marker-comp-op" => "blending", "torque-blend-mode" => "blending", "line-comp-op" => "blending" }.freeze BLENDING_ALIAS = { 'source-over' => 'src-over' }.freeze ANIMATED_TYPES = ['animation', 'heatmap'].freeze def wizard_properties_properties_to_style_properties_properties(wizard_properties_properties, type) spp = {} wpp = wizard_properties_properties return spp unless wpp apply_direct_mapping(spp, wpp, PROPERTIES_DIRECT_MAPPING) if spp['blending'].blank? spp['blending'] = 'none' else spp['blending'] = BLENDING_ALIAS.fetch(spp['blending'], spp['blending']) end merge_into_if_present(spp, 'stroke', generate_stroke(wpp)) merge_into_if_present(spp, drawing_property(wpp), generate_drawing_properties(wpp)) merge_into_if_present(spp, 'labels', generate_labels(wpp)) if ANIMATED_TYPES.include?(type) merge_into_if_present(spp, 'animated', generate_animated(wpp)) end set_animated_style(spp) if type == 'animation' set_property(spp, wpp) spp end def drawing_property(wpp) wpp['geometry_type'] == 'line' ? 'stroke' : 'fill' end def set_property(spp, wpp) return unless wpp['property'] drawing_property = drawing_property(wpp) destination = case @source_type when 'bubble' spp[drawing_property]['size'] when 'choropleth', 'category' spp[drawing_property]['color'] when 'torque', 'torque_heat', 'torque_cat' spp['animated'] when 'density' spp['aggregation']['value'] else # Ignore some malformed wizards that have a property set even when the type does not support it return end destination['attribute'] = wpp['property'] end def set_animated_style(spp) spp['style'] = @source_type == 'torque_heat' ? 'heatmap' : 'simple' end def generate_drawing_properties(wpp) fill = {} merge_into_if_present(fill, @source_type == 'bubble' ? 'size' : 'color', generate_dimension_properties(wpp)) case @source_type when 'polygon', 'torque', 'torque_cat' fill['size'] = { 'fixed' => wpp['marker-width'] }.merge(fill['size'] || {}) when 'choropleth', 'category' fill['size'] = { 'fixed' => 10 }.merge(fill['size'] || {}) when 'torque_heat' fill['size'] = { 'fixed' => 35 }.merge(fill['size'] || {}) end merge_into_if_present(fill, 'color', generate_color(wpp)) fill end STROKE_FROM_POINT_MAPPING = { 'size' => { "marker-line-width" => 'fixed' }, 'color' => { "marker-line-color" => 'fixed', "marker-line-opacity" => 'opacity' } }.freeze STROKE_FROM_NON_POINT_MAPPING = { 'size' => { "line-width" => 'fixed' }, 'color' => { "line-color" => 'fixed', "line-opacity" => 'opacity' } }.freeze def generate_stroke(wpp) stroke_mapping = if wpp['geometry_type'] == 'point' && @source_type != 'density' STROKE_FROM_POINT_MAPPING else STROKE_FROM_NON_POINT_MAPPING end stroke = {} merge_into_if_present(stroke, 'size', apply_direct_mapping({}, wpp, stroke_mapping['size'])) merge_into_if_present(stroke, 'color', apply_direct_mapping({}, wpp, stroke_mapping['color'])) stroke end TORQUE_HEAT_COLOR_DEFAULTS = { 'attribute' => 'points_agg', 'range' => ['blue', 'cyan', 'lightgreen', 'yellow', 'orange', 'red'], 'bins' => 6 }.freeze def generate_color(wpp) color = {} %w(polygon marker).each do |prefix| set_if_present(color, 'fixed', wpp["#{prefix}-fill"]) unless color['opacity'] set_if_present(color, 'opacity', wpp["#{prefix}-opacity"]) end apply_default_opacity(color) end if wpp['categories'].present? color['range'] = wpp['categories'].map { |c| c['color'] } color['domain'] = wpp['categories'].map { |c| c['title'] } end color_attribute = if wpp['property_cat'] wpp['property_cat'] elsif @source_type == 'density' 'agg_value' end color['attribute'] = color_attribute if color_attribute color.merge!(TORQUE_HEAT_COLOR_DEFAULTS) if @source_type == 'torque_heat' color end SIZE_DIRECT_MAPPING = { 'qfunction' => 'quantification' }.freeze QUANTIFICATION_MAPPING = { 'Jenks' => 'jenks', 'Equal Interval' => 'equal', 'Heads/Tails' => 'headtails', 'Quantile' => 'quantiles' }.freeze COLOR_RANGE_SOURCE_TYPES = ['choropleth', 'density'].freeze def generate_dimension_properties(wpp) size = {} radius_min = wpp['radius_min'] radius_max = wpp['radius_max'] if radius_min && radius_max size['range'] = [radius_min, radius_max] end apply_direct_mapping(size, wpp, SIZE_DIRECT_MAPPING) quantification = size['quantification'] size['quantification'] = QUANTIFICATION_MAPPING.fetch(quantification, quantification) if quantification.present? if %w{ bubble category }.include?(@source_type) size['bins'] = 10 end if COLOR_RANGE_SOURCE_TYPES.include?(@source_type) # Commented because it might not be definitive # size['range'] = colorbrewer_ramp_array_from_color_ramp(wpp['color_ramp']) size['range'] = wpp['color_ramp'] size['bins'] = extract_bins_from_method(wpp['method']).to_i end size end ANIMATED_DIRECT_MAPPING = { 'torque-cumulative' => 'overlap', 'torque-duration' => 'duration', 'torque-frame-count' => 'steps', 'torque-resolution' => 'resolution', 'torque-trails' => 'trails' }.freeze DEFAULT_ANIMATED = { 'attribute' => nil, 'overlap' => false, 'duration' => 30, 'steps' => 256, 'resolution' => 2, 'trails' => 2 }.freeze def generate_animated(wpp) animated = {} apply_direct_mapping(animated, wpp, ANIMATED_DIRECT_MAPPING) DEFAULT_ANIMATED.merge(animated) end AGGREGATION_SOURCE_TYPES = %w{ density torque_heat }.freeze def generate_aggregation(wpp) return {} unless AGGREGATION_SOURCE_TYPES.include?(@source_type) size = case @source_type when 'density' wpp['polygon-size'] || 100 when 'torque_heat' wpp['torque-resolution'] else raise "Unsupported source type for aggregation: #{@source_type}" end { "size" => size, "value" => { "operator" => 'COUNT', "attribute" => '' } } end # Taken from `lib/assets/javascripts/cartodb/models/color_ramps.js` COLOR_ARRAYS_FROM_RAMPS = { 'pink' => "['#E7E1EF', '#C994C7', '#DD1C77']", 'red' => "['#FFEDA0', '#FEB24C', '#F03B20']", 'black' => "['#F0F0F0', '#BDBDBD', '#636363']", 'green' => "['#E5F5F9', '#99D8C9', '#2CA25F']", 'blue' => "['#EDF8B1', '#7FCDBB', '#2C7FB8']", 'inverted_pink' => "['#DD1C77','#C994C7','#E7E1EF']", 'inverted_red' => "['#F03B20','#FEB24C','#FFEDA0']", 'inverted_black' => "['#636363','#BDBDBD','#F0F0F0']", 'inverted_green' => "['#2CA25F','#99D8C9','#E5F5F9']", 'inverted_blue' => "['#2C7FB8','#7FCDBB','#EDF8B1']", 'spectrum1' => "['#1a9850', '#fff2cc', '#d73027']", 'spectrum2' => "['#0080ff', '#fff2cc', '#ff4d4d']", 'blue_states' => "['#ECF0F6', '#6182B5', '#43618F']", 'purple_states' => "['#F1E6F1', '#B379B3', '#8A4E8A']", 'red_states' => "['#F2D2D3', '#D4686C', '#C1373C']", 'inverted_blue_states' => "['#43618F', '#6182B5', '#ECF0F6']", 'inverted_purple_states' => "['#8A4E8A', '#B379B3', '#F1E6F1']", 'inverted_red_states' => "['#C1373C', '#D4686C', '#F2D2D3']" }.freeze def colorbrewer_ramp_array_from_color_ramp(ramp) return [] unless ramp COLOR_ARRAYS_FROM_RAMPS[ramp] end DEFAULT_BINS = 6 def extract_bins_from_method(method) return DEFAULT_BINS unless method number_match = method.match(/(\d*) Buckets/i) number_match && number_match[1] ? number_match[1] : DEFAULT_BINS end TEXT_DIRECT_MAPPING = { 'text-name' => 'attribute', 'text-face-name' => 'font', 'text-dy' => 'offset', 'text-allow-overlap' => 'overlap', 'text-placement-type' => 'placement' }.freeze DEFAULT_LABELS = { 'enabled' => false, 'attribute' => nil, 'font' => 'DejaVu Sans Book', 'fill' => { 'size' => { 'fixed' => 10 }, 'color' => { 'fixed' => '#000', 'opacity' => 1 } }, 'halo' => { 'size' => { 'fixed' => 1 }, 'color' => { 'fixed' => '#111', 'opacity' => 1 } }, 'offset' => -10, 'overlap' => true, 'placement' => 'point' }.freeze def generate_labels(wpp) labels = {} apply_direct_mapping(labels, wpp, TEXT_DIRECT_MAPPING) labels['attribute'] = nil if labels['attribute'].to_s.downcase == 'none' merge_into_if_present(labels, 'fill', generate_labels_fill(wpp)) merge_into_if_present(labels, 'halo', generate_labels_halo(wpp)) labels['enabled'] = labels['attribute'].present? DEFAULT_LABELS.merge(labels) end def generate_labels_fill(wpp) labels_fill = {} merge_into_if_present(labels_fill, 'size', generate_labels_fill_size(wpp)) merge_into_if_present(labels_fill, 'color', generate_labels_fill_color(wpp)) labels_fill end TEXT_SIZE_DIRECT_MAPPING = { 'text-size' => 'fixed' }.freeze TEXT_COLOR_DIRECT_MAPPING = { 'text-fill' => 'fixed', 'text-opacity' => 'opacity' }.freeze def generate_labels_fill_size(wpp) size = {} apply_direct_mapping(size, wpp, TEXT_SIZE_DIRECT_MAPPING) size end def generate_labels_fill_color(wpp) color = {} apply_direct_mapping(color, wpp, TEXT_COLOR_DIRECT_MAPPING) apply_default_opacity(color) color end def generate_labels_halo(wpp) labels_halo = {} merge_into_if_present(labels_halo, 'size', generate_labels_halo_size(wpp)) merge_into_if_present(labels_halo, 'color', generate_labels_halo_color(wpp)) labels_halo end HALO_SIZE_DIRECT_MAPPING = { 'text-halo-radius' => 'fixed' }.freeze HALO_COLOR_DIRECT_MAPPING = { 'text-halo-fill' => 'fixed', 'text-halo-opacity' => 'opacity' }.freeze def generate_labels_halo_size(wpp) size = {} apply_direct_mapping(size, wpp, HALO_SIZE_DIRECT_MAPPING) size end def generate_labels_halo_color(wpp) color = {} apply_direct_mapping(color, wpp, HALO_COLOR_DIRECT_MAPPING) apply_default_opacity(color) color end def create_time_series_widget(wpp) if wpp['property'] && @layer.options[:source] @layer.widgets.create( type: 'time-series', order: 0, title: 'time_date__t', options: { column: wpp['property'], bins: 256, sync_on_data_change: true, sync_on_bbox_change: true }, source_id: @layer.options[:source] ) end end end end end