app/controllers/carto/api/layer_presenter.rb
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 StandardError => e
log_error(exception: e)
throw e
end
def infowindow_data_v2
whitelisted_infowindow(with_template(@layer.infowindow, @layer.infowindow_template_path))
rescue StandardError => e
log_error(exception: e)
throw e
end
def tooltip_data_v2
whitelisted_infowindow(with_template(@layer.tooltip, @layer.tooltip_template_path))
rescue StandardError => e
log_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