From 8d13b9f3e9b6823cacc8003a4b40af7d24315d35 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Mon, 4 Dec 2023 15:59:22 -0500 Subject: [PATCH] bitmap and bitmap tile layer (#288) Continuation of https://github.com/developmentseed/lonboard/pull/285, closes https://github.com/developmentseed/lonboard/issues/206 --------- Co-authored-by: Jt Miclat --- docs/api/layers/bitmap-layer.md | 6 + docs/api/layers/bitmap-tile-layer.md | 6 + lonboard/__init__.py | 9 +- lonboard/_layer.py | 285 ++++++++++++++++++++++++++- lonboard/_map.py | 8 +- src/model/layer.ts | 210 ++++++++++++++++---- 6 files changed, 476 insertions(+), 48 deletions(-) create mode 100644 docs/api/layers/bitmap-layer.md create mode 100644 docs/api/layers/bitmap-tile-layer.md diff --git a/docs/api/layers/bitmap-layer.md b/docs/api/layers/bitmap-layer.md new file mode 100644 index 00000000..cb56410c --- /dev/null +++ b/docs/api/layers/bitmap-layer.md @@ -0,0 +1,6 @@ +# BitmapLayer + +::: lonboard.BitmapLayer + options: + show_bases: false + inherited_members: true diff --git a/docs/api/layers/bitmap-tile-layer.md b/docs/api/layers/bitmap-tile-layer.md new file mode 100644 index 00000000..d95d39fe --- /dev/null +++ b/docs/api/layers/bitmap-tile-layer.md @@ -0,0 +1,6 @@ +# BitmapTileLayer + +::: lonboard.BitmapTileLayer + options: + show_bases: false + inherited_members: true diff --git a/lonboard/__init__.py b/lonboard/__init__.py index 7e6fb49d..b798b894 100644 --- a/lonboard/__init__.py +++ b/lonboard/__init__.py @@ -2,7 +2,14 @@ """ from . import colormap, traits -from ._layer import HeatmapLayer, PathLayer, ScatterplotLayer, SolidPolygonLayer +from ._layer import ( + BitmapLayer, + BitmapTileLayer, + HeatmapLayer, + PathLayer, + ScatterplotLayer, + SolidPolygonLayer, +) from ._map import Map from ._version import __version__ from ._viz import viz diff --git a/lonboard/_layer.py b/lonboard/_layer.py index fa333574..beaa2fe1 100644 --- a/lonboard/_layer.py +++ b/lonboard/_layer.py @@ -8,6 +8,7 @@ import ipywidgets import pyarrow as pa import traitlets +from shapely.geometry import box from lonboard._base import BaseExtension, BaseWidget from lonboard._constants import EPSG_4326, EXTENSION_NAME, OGC_84 @@ -24,8 +25,6 @@ class BaseLayer(BaseWidget): - table: traitlets.TraitType - extensions = traitlets.List(trait=traitlets.Instance(BaseExtension)).tag( sync=True, **ipywidgets.widget_serialization ) @@ -104,6 +103,10 @@ class BaseLayer(BaseWidget): _rows_per_chunk = traitlets.Int() """Number of rows per chunk for serializing table and accessor columns.""" + +class BaseArrowLayer(BaseLayer): + table: traitlets.TraitType + @traitlets.default("_rows_per_chunk") def _default_rows_per_chunk(self): return infer_rows_per_chunk(self.table) @@ -141,7 +144,269 @@ def from_geopandas( return cls(table=table, **kwargs) -class ScatterplotLayer(BaseLayer): +class BitmapLayer(BaseLayer): + """ + The `BitmapLayer` renders a bitmap (e.g. PNG, JPEG, or WebP) at specified + boundaries. + + **Example:** + + ```py + from lonboard import Map, BitmapLayer + + layer = BitmapLayer( + image='https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/sf-districts.png', + bounds=[-122.5190, 37.7045, -122.355, 37.829] + ) + m = Map(layers=[layer]) + m + ``` + """ + + _layer_type = traitlets.Unicode("bitmap").tag(sync=True) + + image = traitlets.Unicode().tag(sync=True) + """The URL to an image to display. + + - Type: `str` + """ + + bounds = traitlets.Union( + [ + traitlets.List(traitlets.Float(), minlen=4, maxlen=4), + traitlets.List( + traitlets.List(traitlets.Float(), minlen=2, maxlen=2), + minlen=4, + maxlen=4, + ), + ] + ).tag(sync=True) + """The bounds of the image. + + Supported formats: + + - Coordinates of the bounding box of the bitmap `[left, bottom, right, top]` + - Coordinates of four corners of the bitmap, should follow the sequence of + `[[left, bottom], [left, top], [right, top], [right, bottom]]`. + """ + + desaturate = traitlets.Float(0, min=0, max=1).tag(sync=True) + """The desaturation of the bitmap. Between `[0, 1]`. + + - Type: `float`, optional + - Default: `0` + """ + + transparent_color = traitlets.List( + traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4 + ) + """The color to use for transparent pixels, in `[r, g, b, a]`. + + - Type: `List[float]`, optional + - Default: `[0, 0, 0, 0]` + """ + + tint_color = traitlets.List( + traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4 + ) + """The color to tint the bitmap by, in `[r, g, b]`. + + - Type: `List[float]`, optional + - Default: `[255, 255, 255]` + """ + + # hack to get initial view state to consider bounds/image + @property + def table(self): + gdf = gpd.GeoDataFrame(geometry=[box(*self.bounds)]) # type: ignore + table = geopandas_to_geoarrow(gdf) + return table + + +class BitmapTileLayer(BaseLayer): + """ + The BitmapTileLayer renders image tiles (e.g. PNG, JPEG, or WebP) in the web + mercator tiling system. Only the tiles visible in the current viewport are loaded + and rendered. + + **Example:** + + ```py + from lonboard import Map, BitmapTileLayer + + layer = BitmapTileLayer( + data=[ + 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png', + ], + tile_size=256, + ) + m = Map(layers=[layer]) + ``` + """ + + _layer_type = traitlets.Unicode("bitmap-tile").tag(sync=True) + + data = traitlets.Union( + [traitlets.Unicode(), traitlets.List(traitlets.Unicode(), minlen=1)] + ).tag(sync=True) + """ + Either a URL template or an array of URL templates from which the tile data should + be loaded. + + If the value is a string: a URL template. Substrings {x} {y} and {z}, if present, + will be replaced with a tile's actual index when it is requested. + + If the value is an array: multiple URL templates. Each endpoint must return the same + content for the same tile index. This can be used to work around domain sharding, + allowing browsers to download more resources simultaneously. Requests made are + balanced among the endpoints, based on the tile index. + """ + + tile_size = traitlets.Int(None, allow_none=True).tag(sync=True) + """ + The pixel dimension of the tiles, usually a power of 2. + + Tile size represents the target pixel width and height of each tile when rendered. + Smaller tile sizes display the content at higher resolution, while the layer needs + to load more tiles to fill the same viewport. + + - Type: `int`, optional + - Default: `512` + """ + + zoom_offset = traitlets.Int(None, allow_none=True).tag(sync=True) + """ + This offset changes the zoom level at which the tiles are fetched. Needs to be an + integer. + + - Type: `int`, optional + - Default: `0` + """ + + max_zoom = traitlets.Int(None, allow_none=True).tag(sync=True) + """ + The max zoom level of the layer's data. When overzoomed (i.e. `zoom > max_zoom`), + tiles from this level will be displayed. + + - Type: `int`, optional + - Default: `None` + """ + + min_zoom = traitlets.Int(None, allow_none=True).tag(sync=True) + """ + The min zoom level of the layer's data. When underzoomed (i.e. `zoom < min_zoom`), + the layer will not display any tiles unless `extent` is defined, to avoid issuing + too many tile requests. + + - Type: `int`, optional + - Default: `None` + """ + + extent = traitlets.List( + traitlets.Float(), minlen=4, maxlen=4, allow_none=True, default_value=None + ).tag(sync=True) + """ + The bounding box of the layer's data, in the form of `[min_x, min_y, max_x, max_y]`. + If provided, the layer will only load and render the tiles that are needed to fill + this box. + + - Type: `List[float]`, optional + - Default: `None` + """ + + max_cache_size = traitlets.Int(None, allow_none=True).tag(sync=True) + """ + The maximum number of tiles that can be cached. The tile cache keeps loaded tiles in + memory even if they are no longer visible. It reduces the need to re-download the + same data over and over again when the user pan/zooms around the map, providing a + smoother experience. + + If not supplied, the `max_cache_size` is calculated as 5 times the number of tiles + in the current viewport. + + - Type: `int`, optional + - Default: `None` + """ + + # TODO: Not sure if `getTileData` returns a `byteLength`? + # max_cache_byte_size = traitlets.Int(None, allow_none=True).tag(sync=True) + # """ + # """ + + refinement_strategy = traitlets.Unicode(None, allow_none=True).tag(sync=True) + """How the tile layer refines the visibility of tiles. + + When zooming in and out, if the layer only shows tiles from the current zoom level, + then the user may observe undesirable flashing while new data is loading. By setting + `refinement_strategy` the layer can attempt to maintain visual continuity by + displaying cached data from a different zoom level before data is available. + + This prop accepts one of the following: + + - `"best-available"`: If a tile in the current viewport is waiting for its data to + load, use cached content from the closest zoom level to fill the empty space. This + approach minimizes the visual flashing due to missing content. + - `"no-overlap"`: Avoid showing overlapping tiles when backfilling with cached + content. This is usually favorable when tiles do not have opaque backgrounds. + - `"never"`: Do not display any tile that is not selected. + + - Type: `str`, optional + - Default: `"best-available"` + """ + + max_requests = traitlets.Int(None, allow_none=True).tag(sync=True) + """The maximum number of concurrent data fetches. + + If <= 0, no throttling will occur, and `get_tile_data` may be called an unlimited + number of times concurrently regardless of how long that tile is or was visible. + + If > 0, a maximum of `max_requests` instances of `get_tile_data` will be called + concurrently. Requests may never be called if the tile wasn't visible long enough to + be scheduled and started. Requests may also be aborted (through the signal passed to + `get_tile_data`) if there are more than `max_requests` ongoing requests and some of + those are for tiles that are no longer visible. + + If `get_tile_data` makes fetch requests against an HTTP 1 web server, then + max_requests should correlate to the browser's maximum number of concurrent fetch + requests. For Chrome, the max is 6 per domain. If you use the data prop and specify + multiple domains, you can increase this limit. For example, with Chrome and 3 + domains specified, you can set max_requests=18. + + If the web server supports HTTP/2 (Open Chrome dev tools and look for "h2" in the + Protocol column), then you can make an unlimited number of concurrent requests (and + can set max_requests=-1). Note that this will request data for every tile, no matter + how long the tile was visible, and may increase server load. + """ + + desaturate = traitlets.Float(0, min=0, max=1).tag(sync=True) + """The desaturation of the bitmap. Between `[0, 1]`. + + - Type: `float`, optional + - Default: `0` + """ + + transparent_color = traitlets.List( + traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4 + ) + """The color to use for transparent pixels, in `[r, g, b, a]`. + + - Type: `List[float]`, optional + - Default: `[0, 0, 0, 0]` + """ + + tint_color = traitlets.List( + traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4 + ) + """The color to tint the bitmap by, in `[r, g, b]`. + + - Type: `List[float]`, optional + - Default: `[255, 255, 255]` + """ + + +class ScatterplotLayer(BaseArrowLayer): """The `ScatterplotLayer` renders circles at given coordinates. **Example:** @@ -156,7 +421,7 @@ class ScatterplotLayer(BaseLayer): gdf, get_fill_color=[255, 0, 0], ) - map_ = Map(layers=[layer]) + m = Map(layers=[layer]) ``` """ @@ -333,7 +598,7 @@ def _validate_accessor_length(self, proposal): return proposal["value"] -class PathLayer(BaseLayer): +class PathLayer(BaseArrowLayer): """ The `PathLayer` renders lists of coordinate points as extruded polylines with mitering. @@ -351,7 +616,7 @@ class PathLayer(BaseLayer): get_color=[255, 0, 0], width_min_pixels=2, ) - map_ = Map(layers=[layer]) + m = Map(layers=[layer]) ``` """ @@ -467,7 +732,7 @@ def _validate_accessor_length(self, proposal): return proposal["value"] -class SolidPolygonLayer(BaseLayer): +class SolidPolygonLayer(BaseArrowLayer): """ The `SolidPolygonLayer` renders filled and/or extruded polygons. @@ -483,7 +748,7 @@ class SolidPolygonLayer(BaseLayer): gdf, get_fill_color=[255, 0, 0], ) - map_ = Map(layers=[layer]) + m = Map(layers=[layer]) ``` """ @@ -589,7 +854,7 @@ def _validate_accessor_length(self, proposal): return proposal["value"] -class HeatmapLayer(BaseLayer): +class HeatmapLayer(BaseArrowLayer): """The `HeatmapLayer` visualizes the spatial distribution of data. **Example:** @@ -601,7 +866,7 @@ class HeatmapLayer(BaseLayer): # A GeoDataFrame with Point geometries gdf = gpd.GeoDataFrame() layer = HeatmapLayer.from_geopandas(gdf,) - map_ = Map(layers=[layer]) + m = Map(layers=[layer]) ``` """ diff --git a/lonboard/_map.py b/lonboard/_map.py index 143923e0..ee6cee8a 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -41,7 +41,7 @@ class Map(BaseAnyWidget): get_fill_color=[255, 0, 0], ) - map_ = Map(layers=[point_layer, polygon_layer]) + m = Map(layers=[point_layer, polygon_layer]) ``` """ @@ -130,7 +130,11 @@ def to_html(self, filename: Union[str, Path]) -> None: @traitlets.default("_initial_view_state") def _default_initial_view_state(self): - tables = [layer.table for layer in self.layers if layer.table] + tables = [ + layer.table + for layer in self.layers + if hasattr(layer, "table") and layer.table + ] if tables: return compute_view(tables) else: diff --git a/src/model/layer.ts b/src/model/layer.ts index 6222d33b..45e3d55e 100644 --- a/src/model/layer.ts +++ b/src/model/layer.ts @@ -3,6 +3,7 @@ import type { LayerExtension, LayerProps, PickingInfo, + Texture, } from "@deck.gl/core/typed"; import { GeoArrowArcLayer, @@ -26,10 +27,10 @@ import { parseParquetBuffers } from "../parquet.js"; import { loadChildModels } from "../util.js"; import { BaseModel } from "./base.js"; import { BaseExtensionModel, initializeExtension } from "./extension.js"; +import { BitmapLayer, BitmapLayerProps } from "@deck.gl/layers/typed"; +import { TileLayer, TileLayerProps } from "@deck.gl/geo-layers/typed"; export abstract class BaseLayerModel extends BaseModel { - protected table!: arrow.Table; - protected pickable: LayerProps["pickable"]; protected visible: LayerProps["visible"]; protected opacity: LayerProps["opacity"]; @@ -40,8 +41,6 @@ export abstract class BaseLayerModel extends BaseModel { constructor(model: WidgetModel, updateStateCallback: () => void) { super(model, updateStateCallback); - this.initTable("table"); - this.initRegularAttribute("pickable", "pickable"); this.initRegularAttribute("visible", "visible"); this.initRegularAttribute("opacity", "opacity"); @@ -74,8 +73,8 @@ export abstract class BaseLayerModel extends BaseModel { } baseLayerProps(): LayerProps { - console.log("extensions", this.extensionInstances()); - console.log("extensionprops", this.extensionProps()); + // console.log("extensions", this.extensionInstances()); + // console.log("extensionprops", this.extensionProps()); return { extensions: this.extensionInstances(), ...this.extensionProps(), @@ -98,28 +97,6 @@ export abstract class BaseLayerModel extends BaseModel { */ abstract render(): Layer; - /** - * Initialize a Table on the model. - * - * This also watches for changes on the Jupyter model and propagates those - * changes to this class' internal state. - * - * @param {string} pythonName Name of attribute on Python model (usually snake-cased) - */ - initTable(pythonName: string) { - this.table = parseParquetBuffers(this.model.get(pythonName)); - - // Remove all existing change callbacks for this attribute - this.model.off(`change:${pythonName}`); - - const callback = () => { - this.table = parseParquetBuffers(this.model.get(pythonName)); - }; - this.model.on(`change:${pythonName}`, callback); - - this.callbacks.set(`change:${pythonName}`, callback); - } - // NOTE: this is flaky, especially when changing extensions // This is the main place where extensions should still be considered // experimental @@ -160,7 +137,42 @@ export abstract class BaseLayerModel extends BaseModel { } } -export class ArcModel extends BaseLayerModel { +/** + * An abstract base class for a layer that uses an Arrow Table as the data prop. + */ +export abstract class BaseArrowLayerModel extends BaseLayerModel { + protected table!: arrow.Table; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initTable("table"); + } + + /** + * Initialize a Table on the model. + * + * This also watches for changes on the Jupyter model and propagates those + * changes to this class' internal state. + * + * @param {string} pythonName Name of attribute on Python model (usually snake-cased) + */ + initTable(pythonName: string) { + this.table = parseParquetBuffers(this.model.get(pythonName)); + + // Remove all existing change callbacks for this attribute + this.model.off(`change:${pythonName}`); + + const callback = () => { + this.table = parseParquetBuffers(this.model.get(pythonName)); + }; + this.model.on(`change:${pythonName}`, callback); + + this.callbacks.set(`change:${pythonName}`, callback); + } +} + +export class ArcModel extends BaseArrowLayerModel { static layerType = "arc"; protected greatCircle: GeoArrowArcLayerProps["greatCircle"] | null; @@ -227,7 +239,127 @@ export class ArcModel extends BaseLayerModel { } } -export class ColumnModel extends BaseLayerModel { +export class BitmapModel extends BaseLayerModel { + static layerType = "bitmap"; + + protected image: BitmapLayerProps["image"]; + protected bounds: BitmapLayerProps["bounds"]; + protected desaturate: BitmapLayerProps["desaturate"]; + protected transparentColor: BitmapLayerProps["transparentColor"]; + protected tintColor: BitmapLayerProps["tintColor"]; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("image", "image"); + this.initRegularAttribute("bounds", "bounds"); + this.initRegularAttribute("desaturate", "desaturate"); + this.initRegularAttribute("transparent_color", "transparentColor"); + this.initRegularAttribute("tint_color", "tintColor"); + } + + layerProps(): Omit { + return { + ...(this.image && { image: this.image }), + ...(this.bounds && { bounds: this.bounds }), + ...(this.desaturate && { desaturate: this.desaturate }), + ...(this.transparentColor && { transparentColor: this.transparentColor }), + ...(this.tintColor && { tintColor: this.tintColor }), + }; + } + + render(): BitmapLayer { + return new BitmapLayer({ + ...this.baseLayerProps(), + ...this.layerProps(), + data: undefined, + pickable: false, + }); + } +} + +export class BitmapTileModel extends BaseLayerModel { + static layerType = "bitmap-tile"; + + protected data!: TileLayerProps["data"]; + protected tileSize: TileLayerProps["tileSize"]; + protected zoomOffset: TileLayerProps["zoomOffset"]; + protected maxZoom: TileLayerProps["maxZoom"]; + protected minZoom: TileLayerProps["minZoom"]; + protected extent: TileLayerProps["extent"]; + protected maxCacheSize: TileLayerProps["maxCacheSize"]; + protected maxCacheByteSize: TileLayerProps["maxCacheByteSize"]; + protected refinementStrategy: TileLayerProps["refinementStrategy"]; + protected maxRequests: TileLayerProps["maxRequests"]; + + protected desaturate: BitmapLayerProps["desaturate"]; + protected transparentColor: BitmapLayerProps["transparentColor"]; + protected tintColor: BitmapLayerProps["tintColor"]; + + constructor(model: WidgetModel, updateStateCallback: () => void) { + super(model, updateStateCallback); + + this.initRegularAttribute("data", "data"); + + this.initRegularAttribute("tile_size", "tileSize"); + this.initRegularAttribute("zoom_offset", "zoomOffset"); + this.initRegularAttribute("max_zoom", "maxZoom"); + this.initRegularAttribute("min_zoom", "minZoom"); + this.initRegularAttribute("extent", "extent"); + this.initRegularAttribute("max_cache_size", "maxCacheSize"); + this.initRegularAttribute("max_cache_byte_size", "maxCacheByteSize"); + this.initRegularAttribute("refinement_strategy", "refinementStrategy"); + this.initRegularAttribute("max_requests", "maxRequests"); + this.initRegularAttribute("desaturate", "desaturate"); + this.initRegularAttribute("transparent_color", "transparentColor"); + this.initRegularAttribute("tint_color", "tintColor"); + } + + bitmapLayerProps(): Omit { + return { + ...(this.desaturate && { desaturate: this.desaturate }), + ...(this.transparentColor && { transparentColor: this.transparentColor }), + ...(this.tintColor && { tintColor: this.tintColor }), + }; + } + + layerProps(): Omit { + return { + data: this.data, + ...(this.tileSize && { tileSize: this.tileSize }), + ...(this.zoomOffset && { zoomOffset: this.zoomOffset }), + ...(this.maxZoom && { maxZoom: this.maxZoom }), + ...(this.minZoom && { minZoom: this.minZoom }), + ...(this.extent && { extent: this.extent }), + ...(this.maxCacheSize && { maxCacheSize: this.maxCacheSize }), + ...(this.maxCacheByteSize && { maxCacheByteSize: this.maxCacheByteSize }), + ...(this.refinementStrategy && { + refinementStrategy: this.refinementStrategy, + }), + ...(this.maxRequests && { maxRequests: this.maxRequests }), + }; + } + + render(): TileLayer { + return new TileLayer({ + ...this.baseLayerProps(), + ...this.layerProps(), + + renderSubLayers: (props) => { + const [min, max] = props.tile.boundingBox; + + return new BitmapLayer(props, { + ...this.bitmapLayerProps(), + data: undefined, + image: props.data, + bounds: [min[0], min[1], max[0], max[1]], + }); + }, + }); + } +} + +export class ColumnModel extends BaseArrowLayerModel { static layerType = "column"; protected diskResolution: GeoArrowColumnLayerProps["diskResolution"] | null; @@ -328,7 +460,7 @@ export class ColumnModel extends BaseLayerModel { } } -export class HeatmapModel extends BaseLayerModel { +export class HeatmapModel extends BaseArrowLayerModel { static layerType = "heatmap"; protected radiusPixels: GeoArrowHeatmapLayerProps["radiusPixels"] | null; @@ -388,7 +520,7 @@ export class HeatmapModel extends BaseLayerModel { } } -export class PathModel extends BaseLayerModel { +export class PathModel extends BaseArrowLayerModel { static layerType = "path"; protected widthUnits: GeoArrowPathLayerProps["widthUnits"] | null; @@ -441,7 +573,7 @@ export class PathModel extends BaseLayerModel { }); } } -export class ScatterplotModel extends BaseLayerModel { +export class ScatterplotModel extends BaseArrowLayerModel { static layerType = "scatterplot"; protected radiusUnits: GeoArrowScatterplotLayerProps["radiusUnits"] | null; @@ -529,7 +661,7 @@ export class ScatterplotModel extends BaseLayerModel { } } -export class SolidPolygonModel extends BaseLayerModel { +export class SolidPolygonModel extends BaseArrowLayerModel { static layerType = "solid-polygon"; protected filled: GeoArrowSolidPolygonLayerProps["filled"] | null; @@ -576,7 +708,7 @@ export class SolidPolygonModel extends BaseLayerModel { } } -export class TextModel extends BaseLayerModel { +export class TextModel extends BaseArrowLayerModel { static layerType = "text"; protected billboard: GeoArrowTextLayerProps["billboard"] | null; @@ -708,6 +840,14 @@ export async function initializeLayer( layerModel = new ArcModel(model, updateStateCallback); break; + case BitmapModel.layerType: + layerModel = new BitmapModel(model, updateStateCallback); + break; + + case BitmapTileModel.layerType: + layerModel = new BitmapTileModel(model, updateStateCallback); + break; + case ColumnModel.layerType: layerModel = new ColumnModel(model, updateStateCallback); break;